mirror of
https://github.com/alexta69/metube.git
synced 2026-06-13 16:40:05 +00:00
implement tests
This commit is contained in:
@@ -26,14 +26,19 @@ jobs:
|
|||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: ui
|
working-directory: ui
|
||||||
run: pnpm run build
|
run: pnpm run build
|
||||||
- name: Set up Python
|
- name: Run frontend tests
|
||||||
uses: actions/setup-python@v6
|
working-directory: ui
|
||||||
with:
|
run: pnpm exec ng test --watch=false
|
||||||
python-version: '3.13'
|
env:
|
||||||
|
CI: true
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: uv sync --frozen --group dev
|
||||||
- name: Run backend smoke checks
|
- name: Run backend smoke checks
|
||||||
run: python -m compileall app
|
run: python -m compileall app
|
||||||
- name: Run backend tests
|
- name: Run backend tests
|
||||||
run: python -m unittest discover -s app/tests -p "test_*.py"
|
run: uv run pytest app/tests/
|
||||||
- name: Run Trivy filesystem scan
|
- name: Run Trivy filesystem scan
|
||||||
uses: aquasecurity/trivy-action@0.35.0
|
uses: aquasecurity/trivy-action@0.35.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
+5
-5
@@ -623,14 +623,14 @@ def get_custom_dirs():
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
@routes.get(config.URL_PREFIX)
|
@routes.get(config.URL_PREFIX)
|
||||||
def index(request):
|
async def index(request):
|
||||||
response = web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/metube/browser/index.html'))
|
response = web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/metube/browser/index.html'))
|
||||||
if 'metube_theme' not in request.cookies:
|
if 'metube_theme' not in request.cookies:
|
||||||
response.set_cookie('metube_theme', config.DEFAULT_THEME)
|
response.set_cookie('metube_theme', config.DEFAULT_THEME)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@routes.get(config.URL_PREFIX + 'robots.txt')
|
@routes.get(config.URL_PREFIX + 'robots.txt')
|
||||||
def robots(request):
|
async def robots(request):
|
||||||
if config.ROBOTS_TXT:
|
if config.ROBOTS_TXT:
|
||||||
response = web.FileResponse(os.path.join(config.BASE_DIR, config.ROBOTS_TXT))
|
response = web.FileResponse(os.path.join(config.BASE_DIR, config.ROBOTS_TXT))
|
||||||
else:
|
else:
|
||||||
@@ -640,7 +640,7 @@ def robots(request):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
@routes.get(config.URL_PREFIX + 'version')
|
@routes.get(config.URL_PREFIX + 'version')
|
||||||
def version(request):
|
async def version(request):
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"yt-dlp": yt_dlp_version,
|
"yt-dlp": yt_dlp_version,
|
||||||
"version": os.getenv("METUBE_VERSION", "dev")
|
"version": os.getenv("METUBE_VERSION", "dev")
|
||||||
@@ -648,11 +648,11 @@ def version(request):
|
|||||||
|
|
||||||
if config.URL_PREFIX != '/':
|
if config.URL_PREFIX != '/':
|
||||||
@routes.get('/')
|
@routes.get('/')
|
||||||
def index_redirect_root(request):
|
async def index_redirect_root(request):
|
||||||
return web.HTTPFound(config.URL_PREFIX)
|
return web.HTTPFound(config.URL_PREFIX)
|
||||||
|
|
||||||
@routes.get(config.URL_PREFIX[:-1])
|
@routes.get(config.URL_PREFIX[:-1])
|
||||||
def index_redirect_dir(request):
|
async def index_redirect_dir(request):
|
||||||
return web.HTTPFound(config.URL_PREFIX)
|
return web.HTTPFound(config.URL_PREFIX)
|
||||||
|
|
||||||
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
|
routes.static(config.URL_PREFIX + 'download/', config.DOWNLOAD_DIR, show_index=config.DOWNLOAD_DIRS_INDEXABLE)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Pytest configuration: set env and filesystem layout before importing ``main``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_test_env() -> None:
|
||||||
|
if os.environ.get("METUBE_TEST_ENV_READY"):
|
||||||
|
return
|
||||||
|
tmp = tempfile.mkdtemp(prefix="metube-pytest-")
|
||||||
|
base = Path(tmp)
|
||||||
|
browser = base / "ui" / "dist" / "metube" / "browser"
|
||||||
|
browser.mkdir(parents=True)
|
||||||
|
(browser / "index.html").write_text("<html><body></body></html>", encoding="utf-8")
|
||||||
|
dl = base / "downloads"
|
||||||
|
st = base / "state"
|
||||||
|
dl.mkdir(parents=True)
|
||||||
|
st.mkdir(parents=True)
|
||||||
|
os.environ["DOWNLOAD_DIR"] = str(dl)
|
||||||
|
os.environ["STATE_DIR"] = str(st)
|
||||||
|
os.environ["TEMP_DIR"] = str(dl)
|
||||||
|
os.environ["YTDL_OPTIONS"] = "{}"
|
||||||
|
os.environ["YTDL_OPTIONS_FILE"] = ""
|
||||||
|
os.environ["BASE_DIR"] = str(base)
|
||||||
|
os.environ["LOGLEVEL"] = "INFO"
|
||||||
|
os.environ["METUBE_TEST_ENV_READY"] = "1"
|
||||||
|
|
||||||
|
|
||||||
|
_ensure_test_env()
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""HTTP handler tests for ``main`` using mocked ``web.Request`` (no TestServer)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_dqueue(monkeypatch):
|
||||||
|
d = MagicMock()
|
||||||
|
d.initialize = AsyncMock(return_value=None)
|
||||||
|
d.add = AsyncMock(return_value={"status": "ok"})
|
||||||
|
d.cancel = AsyncMock(return_value={"status": "ok"})
|
||||||
|
d.start_pending = AsyncMock(return_value={"status": "ok"})
|
||||||
|
d.cancel_add = MagicMock()
|
||||||
|
d.queue = MagicMock()
|
||||||
|
d.done = MagicMock()
|
||||||
|
d.pending = MagicMock()
|
||||||
|
d.queue.saved_items = MagicMock(return_value=[])
|
||||||
|
d.done.saved_items = MagicMock(return_value=[])
|
||||||
|
d.pending.saved_items = MagicMock(return_value=[])
|
||||||
|
d.get = MagicMock(return_value=([], []))
|
||||||
|
monkeypatch.setattr(main, "dqueue", d)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_video_add_body(**kwargs):
|
||||||
|
base = {
|
||||||
|
"url": "https://example.com/watch?v=1",
|
||||||
|
"download_type": "video",
|
||||||
|
"codec": "auto",
|
||||||
|
"format": "any",
|
||||||
|
"quality": "best",
|
||||||
|
}
|
||||||
|
base.update(kwargs)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _json_request(body: dict | None):
|
||||||
|
req = MagicMock(spec=web.Request)
|
||||||
|
req.json = AsyncMock(return_value=body)
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_ok(mock_dqueue):
|
||||||
|
req = _json_request(_valid_video_add_body())
|
||||||
|
resp = await main.add(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
text = resp.text
|
||||||
|
data = json.loads(text)
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
mock_dqueue.add.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_missing_url_returns_400(mock_dqueue):
|
||||||
|
req = _json_request({"download_type": "video", "quality": "best", "format": "any"})
|
||||||
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
|
await main.add(req)
|
||||||
|
mock_dqueue.add.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_invalid_download_type(mock_dqueue):
|
||||||
|
req = _json_request(_valid_video_add_body(download_type="invalid"))
|
||||||
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
|
await main.add(req)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_invalid_video_quality(mock_dqueue):
|
||||||
|
req = _json_request(_valid_video_add_body(quality="9999"))
|
||||||
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
|
await main.add(req)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_invalid_subtitle_language(mock_dqueue):
|
||||||
|
req = _json_request(
|
||||||
|
{
|
||||||
|
"url": "https://example.com/v",
|
||||||
|
"download_type": "captions",
|
||||||
|
"codec": "auto",
|
||||||
|
"format": "srt",
|
||||||
|
"quality": "best",
|
||||||
|
"subtitle_language": "bad language!",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
|
await main.add(req)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_custom_name_prefix_path_traversal(mock_dqueue):
|
||||||
|
req = _json_request(_valid_video_add_body(custom_name_prefix="../evil"))
|
||||||
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
|
await main.add(req)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_chapter_template_path_traversal(mock_dqueue):
|
||||||
|
req = _json_request(
|
||||||
|
_valid_video_add_body(
|
||||||
|
split_by_chapters=True,
|
||||||
|
chapter_template="/etc/passwd%(title)s",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
|
await main.add(req)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_invalid_json_body(mock_dqueue):
|
||||||
|
req = MagicMock(spec=web.Request)
|
||||||
|
req.json = AsyncMock(side_effect=json.JSONDecodeError("msg", "", 0))
|
||||||
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
|
await main.add(req)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_missing_ids(mock_dqueue):
|
||||||
|
req = _json_request({"where": "queue"})
|
||||||
|
with pytest.raises(web.HTTPBadRequest):
|
||||||
|
await main.delete(req)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_queue_calls_cancel(mock_dqueue):
|
||||||
|
req = _json_request({"where": "queue", "ids": ["http://x"]})
|
||||||
|
resp = await main.delete(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
mock_dqueue.cancel.assert_awaited_once_with(["http://x"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_pending(mock_dqueue):
|
||||||
|
req = _json_request({"ids": ["a"]})
|
||||||
|
resp = await main.start(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
mock_dqueue.start_pending.assert_awaited_once_with(["a"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_shape(mock_dqueue):
|
||||||
|
mock_dqueue.queue.saved_items.return_value = []
|
||||||
|
mock_dqueue.done.saved_items.return_value = []
|
||||||
|
mock_dqueue.pending.saved_items.return_value = []
|
||||||
|
req = MagicMock(spec=web.Request)
|
||||||
|
resp = await main.history(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
data = json.loads(resp.text)
|
||||||
|
assert set(data.keys()) == {"done", "queue", "pending"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_version_json(mock_dqueue):
|
||||||
|
req = MagicMock(spec=web.Request)
|
||||||
|
resp = await main.version(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
body = json.loads(resp.text)
|
||||||
|
assert "yt-dlp" in body and "version" in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cookie_status(mock_dqueue):
|
||||||
|
req = MagicMock(spec=web.Request)
|
||||||
|
resp = await main.cookie_status(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
data = json.loads(resp.text)
|
||||||
|
assert data.get("status") == "ok"
|
||||||
|
assert "has_cookies" in data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_options_add_cors(mock_dqueue):
|
||||||
|
req = MagicMock(spec=web.Request)
|
||||||
|
resp = await main.add_cors(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_cookies_missing_field(mock_dqueue):
|
||||||
|
req = MagicMock(spec=web.Request)
|
||||||
|
reader = MagicMock()
|
||||||
|
field = MagicMock()
|
||||||
|
field.name = "wrongname"
|
||||||
|
reader.next = AsyncMock(side_effect=[field, None])
|
||||||
|
req.multipart = AsyncMock(return_value=reader)
|
||||||
|
resp = await main.upload_cookies(req)
|
||||||
|
assert resp.status == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_legacy_format_migrated(mock_dqueue):
|
||||||
|
req = _json_request({"url": "https://example.com/v", "format": "m4a", "quality": "best"})
|
||||||
|
resp = await main.add(req)
|
||||||
|
assert resp.status == 200
|
||||||
|
call = mock_dqueue.add.await_args
|
||||||
|
assert call is not None
|
||||||
|
assert call.args[1] == "audio"
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Tests for ``Config`` (env parsing, yt-dlp options, frontend_safe)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from main import Config
|
||||||
|
|
||||||
|
|
||||||
|
def _base_env(**overrides: str) -> dict[str, str]:
|
||||||
|
env = {k: str(v) for k, v in Config._DEFAULTS.items()}
|
||||||
|
env.update(overrides)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigTests(unittest.TestCase):
|
||||||
|
def test_url_prefix_gets_trailing_slash(self):
|
||||||
|
with patch.dict(os.environ, _base_env(URL_PREFIX="foo"), clear=False):
|
||||||
|
c = Config()
|
||||||
|
self.assertEqual(c.URL_PREFIX, "foo/")
|
||||||
|
|
||||||
|
def test_ytdl_options_json_loaded(self):
|
||||||
|
opts = {"quiet": True, "no_warnings": True}
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
_base_env(YTDL_OPTIONS=json.dumps(opts)),
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
c = Config()
|
||||||
|
self.assertEqual(c.YTDL_OPTIONS["quiet"], True)
|
||||||
|
|
||||||
|
def test_invalid_ytdl_options_exits(self):
|
||||||
|
with patch.dict(os.environ, _base_env(YTDL_OPTIONS="not-json"), clear=False):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
Config()
|
||||||
|
|
||||||
|
def test_invalid_boolean_env_exits(self):
|
||||||
|
with patch.dict(os.environ, _base_env(CUSTOM_DIRS="maybe"), clear=False):
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
Config()
|
||||||
|
|
||||||
|
def test_frontend_safe_excludes_secrets(self):
|
||||||
|
with patch.dict(os.environ, _base_env(), clear=False):
|
||||||
|
c = Config()
|
||||||
|
safe = c.frontend_safe()
|
||||||
|
self.assertNotIn("YTDL_OPTIONS", safe)
|
||||||
|
self.assertNotIn("HOST", safe)
|
||||||
|
|
||||||
|
def test_runtime_override_roundtrip(self):
|
||||||
|
with patch.dict(os.environ, _base_env(), clear=False):
|
||||||
|
c = Config()
|
||||||
|
c.set_runtime_override("cookiefile", "/tmp/c.txt")
|
||||||
|
self.assertEqual(c.YTDL_OPTIONS.get("cookiefile"), "/tmp/c.txt")
|
||||||
|
c.remove_runtime_override("cookiefile")
|
||||||
|
self.assertIsNone(c.YTDL_OPTIONS.get("cookiefile"))
|
||||||
|
|
||||||
|
def test_ytdl_options_file_merges(self):
|
||||||
|
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump({"extractor_args": {"youtube": {"player_client": ["web"]}}}, f)
|
||||||
|
path = f.name
|
||||||
|
try:
|
||||||
|
with patch.dict(
|
||||||
|
os.environ,
|
||||||
|
_base_env(YTDL_OPTIONS="{}", YTDL_OPTIONS_FILE=path),
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
c = Config()
|
||||||
|
self.assertIn("extractor_args", c.YTDL_OPTIONS)
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
|
"""Tests for ``app.dl_formats`` format selectors and yt-dlp option mapping."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from app.dl_formats import get_format, get_opts
|
from app.dl_formats import (
|
||||||
|
_normalize_caption_mode,
|
||||||
|
_normalize_subtitle_language,
|
||||||
|
get_format,
|
||||||
|
get_opts,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DlFormatsTests(unittest.TestCase):
|
class DlFormatsTests(unittest.TestCase):
|
||||||
@@ -16,6 +26,114 @@ class DlFormatsTests(unittest.TestCase):
|
|||||||
opts = get_opts("audio", "auto", "mp3", "best", {})
|
opts = get_opts("audio", "auto", "mp3", "best", {})
|
||||||
self.assertTrue(opts.get("writethumbnail"))
|
self.assertTrue(opts.get("writethumbnail"))
|
||||||
|
|
||||||
|
def test_custom_format_passthrough(self):
|
||||||
|
self.assertEqual(get_format("video", "auto", "custom:bestvideo+bestaudio", "best"), "bestvideo+bestaudio")
|
||||||
|
|
||||||
|
def test_thumbnail_and_captions_format_strings(self):
|
||||||
|
self.assertEqual(get_format("thumbnail", "auto", "jpg", "best"), "bestaudio/best")
|
||||||
|
self.assertEqual(get_format("captions", "auto", "srt", "best"), "bestaudio/best")
|
||||||
|
|
||||||
|
def test_audio_formats(self):
|
||||||
|
for fmt in ("m4a", "mp3", "opus", "wav", "flac"):
|
||||||
|
with self.subTest(fmt=fmt):
|
||||||
|
self.assertIn(f"ext={fmt}", get_format("audio", "auto", fmt, "best"))
|
||||||
|
|
||||||
|
def test_video_unknown_format_raises(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
get_format("video", "auto", "mkv", "best")
|
||||||
|
|
||||||
|
def test_unknown_download_type_raises(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
get_format("unknown", "auto", "any", "best")
|
||||||
|
|
||||||
|
def test_video_any_mp4_ios_with_height_quality(self):
|
||||||
|
self.assertIn("height<=1080", get_format("video", "auto", "any", "1080"))
|
||||||
|
self.assertNotIn("height<=", get_format("video", "auto", "any", "best"))
|
||||||
|
self.assertNotIn("height<=", get_format("video", "auto", "any", "worst"))
|
||||||
|
|
||||||
|
def test_video_codec_filters(self):
|
||||||
|
self.assertIn("h264", get_format("video", "h264", "any", "best"))
|
||||||
|
self.assertIn("hevc", get_format("video", "h265", "any", "best"))
|
||||||
|
self.assertIn("av0?1", get_format("video", "av1", "any", "best"))
|
||||||
|
self.assertIn("vp0?9", get_format("video", "vp9", "any", "best"))
|
||||||
|
|
||||||
|
def test_video_mp4_includes_m4a_audio(self):
|
||||||
|
s = get_format("video", "auto", "mp4", "720")
|
||||||
|
self.assertIn("[ext=m4a]", s)
|
||||||
|
|
||||||
|
def test_video_ios_selector_contains_avc_pattern(self):
|
||||||
|
s = get_format("video", "auto", "ios", "best")
|
||||||
|
self.assertIn("h26[45]", s)
|
||||||
|
|
||||||
|
def test_get_opts_deepcopy_does_not_mutate_input(self):
|
||||||
|
base = {"postprocessors": [{"key": "Existing"}]}
|
||||||
|
orig = copy.deepcopy(base)
|
||||||
|
get_opts("audio", "auto", "mp3", "best", base)
|
||||||
|
self.assertEqual(base, orig)
|
||||||
|
|
||||||
|
def test_get_opts_audio_m4a_postprocessors(self):
|
||||||
|
opts = get_opts("audio", "auto", "m4a", "best", {})
|
||||||
|
keys = [p["key"] for p in opts["postprocessors"]]
|
||||||
|
self.assertIn("FFmpegExtractAudio", keys)
|
||||||
|
|
||||||
|
def test_get_opts_audio_mp3_quality_not_best(self):
|
||||||
|
opts = get_opts("audio", "auto", "mp3", "192", {})
|
||||||
|
ext = next(p for p in opts["postprocessors"] if p["key"] == "FFmpegExtractAudio")
|
||||||
|
self.assertEqual(ext["preferredquality"], "192")
|
||||||
|
|
||||||
|
def test_get_opts_thumbnail_skip_download(self):
|
||||||
|
opts = get_opts("thumbnail", "auto", "jpg", "best", {})
|
||||||
|
self.assertTrue(opts.get("skip_download"))
|
||||||
|
self.assertTrue(opts.get("writethumbnail"))
|
||||||
|
|
||||||
|
def test_get_opts_captions_manual_only(self):
|
||||||
|
opts = get_opts(
|
||||||
|
"captions", "auto", "vtt", "best", {}, subtitle_language="fr", subtitle_mode="manual_only"
|
||||||
|
)
|
||||||
|
self.assertTrue(opts.get("writesubtitles"))
|
||||||
|
self.assertFalse(opts.get("writeautomaticsub"))
|
||||||
|
self.assertEqual(opts["subtitleslangs"], ["fr"])
|
||||||
|
|
||||||
|
def test_get_opts_captions_auto_only(self):
|
||||||
|
opts = get_opts(
|
||||||
|
"captions", "auto", "srt", "best", {}, subtitle_language="de", subtitle_mode="auto_only"
|
||||||
|
)
|
||||||
|
self.assertFalse(opts.get("writesubtitles"))
|
||||||
|
self.assertTrue(opts.get("writeautomaticsub"))
|
||||||
|
self.assertEqual(opts["subtitleslangs"], ["de-orig", "de"])
|
||||||
|
|
||||||
|
def test_get_opts_captions_prefer_auto(self):
|
||||||
|
opts = get_opts(
|
||||||
|
"captions", "auto", "srt", "best", {}, subtitle_language="es", subtitle_mode="prefer_auto"
|
||||||
|
)
|
||||||
|
self.assertTrue(opts.get("writesubtitles"))
|
||||||
|
self.assertTrue(opts.get("writeautomaticsub"))
|
||||||
|
self.assertEqual(opts["subtitleslangs"], ["es-orig", "es"])
|
||||||
|
|
||||||
|
def test_get_opts_captions_prefer_manual_default_branch(self):
|
||||||
|
opts = get_opts(
|
||||||
|
"captions", "auto", "srt", "best", {}, subtitle_language="it", subtitle_mode="prefer_manual"
|
||||||
|
)
|
||||||
|
self.assertEqual(opts["subtitleslangs"], ["it", "it-orig"])
|
||||||
|
|
||||||
|
def test_get_opts_captions_txt_maps_to_srt_format(self):
|
||||||
|
opts = get_opts("captions", "auto", "txt", "best", {})
|
||||||
|
self.assertEqual(opts["subtitlesformat"], "srt")
|
||||||
|
|
||||||
|
def test_get_opts_merges_existing_postprocessors(self):
|
||||||
|
opts = get_opts("audio", "auto", "opus", "best", {"postprocessors": [{"key": "SponsorBlock"}]})
|
||||||
|
keys = [p["key"] for p in opts["postprocessors"]]
|
||||||
|
self.assertIn("SponsorBlock", keys)
|
||||||
|
self.assertIn("FFmpegExtractAudio", keys)
|
||||||
|
|
||||||
|
def test_normalize_caption_mode_invalid_defaults(self):
|
||||||
|
self.assertEqual(_normalize_caption_mode(""), "prefer_manual")
|
||||||
|
self.assertEqual(_normalize_caption_mode("not_a_mode"), "prefer_manual")
|
||||||
|
|
||||||
|
def test_normalize_subtitle_language_empty_defaults_en(self):
|
||||||
|
self.assertEqual(_normalize_subtitle_language(""), "en")
|
||||||
|
self.assertEqual(_normalize_subtitle_language(" "), "en")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Tests for ``DownloadQueue`` with mocked yt-dlp extraction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ytdl import DownloadQueue
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dq_env():
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
dl = os.path.join(tmp, "downloads")
|
||||||
|
st = os.path.join(tmp, "state")
|
||||||
|
os.makedirs(dl, exist_ok=True)
|
||||||
|
os.makedirs(st, exist_ok=True)
|
||||||
|
cfg = MagicMock()
|
||||||
|
cfg.STATE_DIR = st
|
||||||
|
cfg.DOWNLOAD_DIR = dl
|
||||||
|
cfg.AUDIO_DOWNLOAD_DIR = dl
|
||||||
|
cfg.TEMP_DIR = dl
|
||||||
|
cfg.MAX_CONCURRENT_DOWNLOADS = "3"
|
||||||
|
cfg.YTDL_OPTIONS = {}
|
||||||
|
cfg.CUSTOM_DIRS = True
|
||||||
|
cfg.CREATE_CUSTOM_DIRS = True
|
||||||
|
cfg.CLEAR_COMPLETED_AFTER = "0"
|
||||||
|
cfg.DELETE_FILE_ON_TRASHCAN = False
|
||||||
|
cfg.OUTPUT_TEMPLATE = "%(title)s.%(ext)s"
|
||||||
|
cfg.OUTPUT_TEMPLATE_CHAPTER = "%(title)s.%(ext)s"
|
||||||
|
cfg.OUTPUT_TEMPLATE_PLAYLIST = ""
|
||||||
|
cfg.OUTPUT_TEMPLATE_CHANNEL = ""
|
||||||
|
yield cfg
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_add_increments_generation(dq_env):
|
||||||
|
notifier = MagicMock()
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
before = dq._add_generation
|
||||||
|
dq.cancel_add()
|
||||||
|
assert dq._add_generation == before + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_returns_tuple_of_lists(dq_env):
|
||||||
|
notifier = MagicMock()
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
q, done = dq.get()
|
||||||
|
assert q == [] and done == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_single_video_goes_to_pending_when_auto_start_false(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
|
||||||
|
def fake_extract(self, url):
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": url,
|
||||||
|
"webpage_url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||||
|
result = await dq.add(
|
||||||
|
"https://example.com/watch?v=1",
|
||||||
|
"video",
|
||||||
|
"auto",
|
||||||
|
"any",
|
||||||
|
"best",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
auto_start=False,
|
||||||
|
)
|
||||||
|
assert result["status"] == "ok"
|
||||||
|
assert dq.pending.exists("https://example.com/watch?v=1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancel_removes_from_pending(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
|
||||||
|
def fake_extract(self, url):
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": url,
|
||||||
|
"webpage_url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||||
|
await dq.add(
|
||||||
|
"https://example.com/pending",
|
||||||
|
"video",
|
||||||
|
"auto",
|
||||||
|
"any",
|
||||||
|
"best",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
auto_start=False,
|
||||||
|
)
|
||||||
|
url = "https://example.com/pending"
|
||||||
|
await dq.cancel([url])
|
||||||
|
assert not dq.pending.exists(url)
|
||||||
|
notifier.canceled.assert_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_pending_moves_to_queue(dq_env):
|
||||||
|
notifier = AsyncMock()
|
||||||
|
|
||||||
|
def fake_extract(self, url):
|
||||||
|
return {
|
||||||
|
"_type": "video",
|
||||||
|
"id": "vid1",
|
||||||
|
"title": "Test Video",
|
||||||
|
"url": url,
|
||||||
|
"webpage_url": url,
|
||||||
|
}
|
||||||
|
|
||||||
|
dq = DownloadQueue(dq_env, notifier)
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__extract_info", fake_extract):
|
||||||
|
await dq.add(
|
||||||
|
"https://example.com/startme",
|
||||||
|
"video",
|
||||||
|
"auto",
|
||||||
|
"any",
|
||||||
|
"best",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
auto_start=False,
|
||||||
|
)
|
||||||
|
url = "https://example.com/startme"
|
||||||
|
# Starting will spawn real download — cancel immediately before worker runs much
|
||||||
|
with patch.object(DownloadQueue, "_DownloadQueue__start_download", AsyncMock()):
|
||||||
|
await dq.start_pending([url])
|
||||||
|
assert not dq.pending.exists(url)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Tests for pure helpers in ``main`` (legacy API migration, logging, JSON serializer)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
|
||||||
|
class MigrateLegacyRequestTests(unittest.TestCase):
|
||||||
|
def test_already_new_schema_unchanged(self):
|
||||||
|
post = {"download_type": "video", "codec": "h264", "format": "mp4", "quality": "1080"}
|
||||||
|
before = post.copy()
|
||||||
|
self.assertIs(main._migrate_legacy_request(post), post)
|
||||||
|
self.assertEqual(post, before)
|
||||||
|
|
||||||
|
def test_legacy_audio_m4a(self):
|
||||||
|
post = {"format": "m4a", "quality": "best"}
|
||||||
|
main._migrate_legacy_request(post)
|
||||||
|
self.assertEqual(post["download_type"], "audio")
|
||||||
|
self.assertEqual(post["codec"], "auto")
|
||||||
|
self.assertEqual(post["format"], "m4a")
|
||||||
|
|
||||||
|
def test_legacy_thumbnail(self):
|
||||||
|
post = {"format": "thumbnail", "quality": "best"}
|
||||||
|
main._migrate_legacy_request(post)
|
||||||
|
self.assertEqual(post["download_type"], "thumbnail")
|
||||||
|
self.assertEqual(post["format"], "jpg")
|
||||||
|
self.assertEqual(post["quality"], "best")
|
||||||
|
|
||||||
|
def test_legacy_captions_with_subtitle_format(self):
|
||||||
|
post = {"format": "captions", "subtitle_format": "vtt", "quality": "best"}
|
||||||
|
main._migrate_legacy_request(post)
|
||||||
|
self.assertEqual(post["download_type"], "captions")
|
||||||
|
self.assertEqual(post["format"], "vtt")
|
||||||
|
|
||||||
|
def test_legacy_video_best_ios(self):
|
||||||
|
post = {"format": "any", "quality": "best_ios", "video_codec": "auto"}
|
||||||
|
main._migrate_legacy_request(post)
|
||||||
|
self.assertEqual(post["download_type"], "video")
|
||||||
|
self.assertEqual(post["format"], "ios")
|
||||||
|
self.assertEqual(post["quality"], "best")
|
||||||
|
|
||||||
|
def test_legacy_video_quality_audio_maps_to_m4a(self):
|
||||||
|
post = {"format": "mp4", "quality": "audio", "video_codec": "h264"}
|
||||||
|
main._migrate_legacy_request(post)
|
||||||
|
self.assertEqual(post["download_type"], "audio")
|
||||||
|
self.assertEqual(post["format"], "m4a")
|
||||||
|
self.assertEqual(post["quality"], "best")
|
||||||
|
|
||||||
|
def test_legacy_video_default(self):
|
||||||
|
post = {"format": "mp4", "quality": "1080", "video_codec": "h265"}
|
||||||
|
main._migrate_legacy_request(post)
|
||||||
|
self.assertEqual(post["download_type"], "video")
|
||||||
|
self.assertEqual(post["codec"], "h265")
|
||||||
|
self.assertEqual(post["format"], "mp4")
|
||||||
|
self.assertEqual(post["quality"], "1080")
|
||||||
|
|
||||||
|
|
||||||
|
class ParseLogLevelTests(unittest.TestCase):
|
||||||
|
def test_valid_levels(self):
|
||||||
|
self.assertEqual(main.parseLogLevel("INFO"), logging.INFO)
|
||||||
|
self.assertEqual(main.parseLogLevel("debug"), logging.DEBUG)
|
||||||
|
|
||||||
|
def test_invalid_returns_none(self):
|
||||||
|
self.assertIsNone(main.parseLogLevel("not_a_level"))
|
||||||
|
self.assertIsNone(main.parseLogLevel(123))
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectSerializerTests(unittest.TestCase):
|
||||||
|
def test_dict_like_object(self):
|
||||||
|
class Obj:
|
||||||
|
def __init__(self):
|
||||||
|
self.a = 1
|
||||||
|
|
||||||
|
ser = main.ObjectSerializer()
|
||||||
|
self.assertEqual(json.loads(ser.encode(Obj())), {"a": 1})
|
||||||
|
|
||||||
|
def test_generator_becomes_list(self):
|
||||||
|
ser = main.ObjectSerializer()
|
||||||
|
|
||||||
|
def gen():
|
||||||
|
yield 1
|
||||||
|
yield 2
|
||||||
|
|
||||||
|
self.assertEqual(json.loads(ser.encode(gen())), [1, 2])
|
||||||
|
|
||||||
|
def test_string_not_split_to_chars(self):
|
||||||
|
ser = main.ObjectSerializer()
|
||||||
|
self.assertEqual(json.loads(ser.encode("hello")), "hello")
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendSafeTests(unittest.TestCase):
|
||||||
|
def test_only_expected_keys(self):
|
||||||
|
safe = main.config.frontend_safe()
|
||||||
|
for key in main.Config._FRONTEND_KEYS:
|
||||||
|
self.assertIn(key, safe)
|
||||||
|
self.assertNotIn("YTDL_OPTIONS", safe)
|
||||||
|
self.assertNotIn("DOWNLOAD_DIR", safe)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Integration tests for ``PersistentQueue`` (shelve-backed storage)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from ytdl import DownloadInfo, PersistentQueue
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDownload:
|
||||||
|
__slots__ = ("info",)
|
||||||
|
|
||||||
|
def __init__(self, info: DownloadInfo):
|
||||||
|
self.info = info
|
||||||
|
|
||||||
|
|
||||||
|
def _make_info(url: str = "https://example.com/v") -> DownloadInfo:
|
||||||
|
return DownloadInfo(
|
||||||
|
id="id1",
|
||||||
|
title="Title",
|
||||||
|
url=url,
|
||||||
|
quality="best",
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
folder="",
|
||||||
|
custom_name_prefix="",
|
||||||
|
error=None,
|
||||||
|
entry=None,
|
||||||
|
playlist_item_limit=0,
|
||||||
|
split_by_chapters=False,
|
||||||
|
chapter_template="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PersistentQueueTests(unittest.TestCase):
|
||||||
|
def test_put_get_delete_roundtrip(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "queue")
|
||||||
|
pq = PersistentQueue("queue", path)
|
||||||
|
dl = _FakeDownload(_make_info("http://a.example"))
|
||||||
|
pq.put(dl)
|
||||||
|
self.assertTrue(pq.exists("http://a.example"))
|
||||||
|
self.assertFalse(pq.empty())
|
||||||
|
got = pq.get("http://a.example")
|
||||||
|
self.assertEqual(got.info.url, "http://a.example")
|
||||||
|
pq.delete("http://a.example")
|
||||||
|
self.assertFalse(pq.exists("http://a.example"))
|
||||||
|
|
||||||
|
def test_saved_items_sorted_by_timestamp(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "queue")
|
||||||
|
pq = PersistentQueue("queue", path)
|
||||||
|
a = _FakeDownload(_make_info("http://first.example"))
|
||||||
|
b = _FakeDownload(_make_info("http://second.example"))
|
||||||
|
a.info.timestamp = 100
|
||||||
|
b.info.timestamp = 200
|
||||||
|
pq.put(a)
|
||||||
|
pq.put(b)
|
||||||
|
keys = [k for k, _ in pq.saved_items()]
|
||||||
|
self.assertEqual(keys, ["http://first.example", "http://second.example"])
|
||||||
|
|
||||||
|
def test_load_restores_from_shelve(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "queue")
|
||||||
|
pq1 = PersistentQueue("queue", path)
|
||||||
|
pq1.put(_FakeDownload(_make_info("http://load.example")))
|
||||||
|
pq2 = PersistentQueue("queue", path)
|
||||||
|
pq2.load()
|
||||||
|
self.assertTrue(pq2.exists("http://load.example"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""Tests for pure helpers and migration logic in ``ytdl``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ytdl import (
|
||||||
|
DownloadInfo,
|
||||||
|
_convert_generators_to_lists,
|
||||||
|
_convert_srt_to_txt_file,
|
||||||
|
_outtmpl_substitute_field,
|
||||||
|
_sanitize_path_component,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SanitizePathComponentTests(unittest.TestCase):
|
||||||
|
def test_replaces_windows_invalid_chars(self):
|
||||||
|
self.assertEqual(_sanitize_path_component('a:b*c?d"e<f>g|h'), "a_b_c_d_e_f_g_h")
|
||||||
|
|
||||||
|
def test_non_string_passthrough(self):
|
||||||
|
self.assertIs(_sanitize_path_component(None), None)
|
||||||
|
self.assertEqual(_sanitize_path_component(42), 42)
|
||||||
|
|
||||||
|
|
||||||
|
class OuttmplSubstituteFieldTests(unittest.TestCase):
|
||||||
|
def test_simple_substitution(self):
|
||||||
|
self.assertEqual(_outtmpl_substitute_field("%(title)s", "title", "Hello"), "Hello")
|
||||||
|
|
||||||
|
def test_format_spec_int(self):
|
||||||
|
self.assertEqual(_outtmpl_substitute_field("%(idx)02d", "idx", 3), "03")
|
||||||
|
|
||||||
|
def test_missing_field_unchanged(self):
|
||||||
|
self.assertEqual(_outtmpl_substitute_field("%(other)s", "title", "x"), "%(other)s")
|
||||||
|
|
||||||
|
|
||||||
|
class ConvertGeneratorsToListsTests(unittest.TestCase):
|
||||||
|
def test_nested(self):
|
||||||
|
def g():
|
||||||
|
yield 1
|
||||||
|
|
||||||
|
obj = {"a": g(), "b": [g()]}
|
||||||
|
out = _convert_generators_to_lists(obj)
|
||||||
|
self.assertEqual(out, {"a": [1], "b": [[1]]})
|
||||||
|
|
||||||
|
def test_plain(self):
|
||||||
|
self.assertEqual(_convert_generators_to_lists(5), 5)
|
||||||
|
|
||||||
|
|
||||||
|
class ConvertSrtToTxtTests(unittest.TestCase):
|
||||||
|
def test_basic_conversion(self):
|
||||||
|
srt = """1
|
||||||
|
00:00:01,000 --> 00:00:02,000
|
||||||
|
Hello <b>world</b>
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:03,000 --> 00:00:04,000
|
||||||
|
Second line
|
||||||
|
"""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = Path(tmp) / "sub.srt"
|
||||||
|
path.write_text(srt, encoding="utf-8")
|
||||||
|
txt_path = _convert_srt_to_txt_file(str(path))
|
||||||
|
self.assertIsNotNone(txt_path)
|
||||||
|
self.assertTrue(txt_path.endswith(".txt"))
|
||||||
|
content = Path(txt_path).read_text(encoding="utf-8")
|
||||||
|
self.assertIn("Hello world", content)
|
||||||
|
self.assertIn("Second line", content)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadInfoSetstateTests(unittest.TestCase):
|
||||||
|
def _base_state(self, **kwargs):
|
||||||
|
base = {
|
||||||
|
"id": "id1",
|
||||||
|
"title": "t",
|
||||||
|
"url": "http://example.com/v",
|
||||||
|
"folder": "",
|
||||||
|
"custom_name_prefix": "",
|
||||||
|
"error": None,
|
||||||
|
"entry": None,
|
||||||
|
"playlist_item_limit": 0,
|
||||||
|
"split_by_chapters": False,
|
||||||
|
"chapter_template": "",
|
||||||
|
"msg": None,
|
||||||
|
"percent": None,
|
||||||
|
"speed": None,
|
||||||
|
"eta": None,
|
||||||
|
"status": "pending",
|
||||||
|
"size": None,
|
||||||
|
"timestamp": 0,
|
||||||
|
}
|
||||||
|
base.update(kwargs)
|
||||||
|
return base
|
||||||
|
|
||||||
|
def test_migrates_old_audio_format(self):
|
||||||
|
state = self._base_state(format="m4a", quality="best")
|
||||||
|
di = DownloadInfo.__new__(DownloadInfo)
|
||||||
|
di.__setstate__(state)
|
||||||
|
self.assertEqual(di.download_type, "audio")
|
||||||
|
self.assertEqual(di.codec, "auto")
|
||||||
|
|
||||||
|
def test_migrates_thumbnail(self):
|
||||||
|
state = self._base_state(format="thumbnail", quality="best")
|
||||||
|
di = DownloadInfo.__new__(DownloadInfo)
|
||||||
|
di.__setstate__(state)
|
||||||
|
self.assertEqual(di.download_type, "thumbnail")
|
||||||
|
self.assertEqual(di.format, "jpg")
|
||||||
|
|
||||||
|
def test_migrates_captions(self):
|
||||||
|
state = self._base_state(format="captions", subtitle_format="vtt", quality="best")
|
||||||
|
di = DownloadInfo.__new__(DownloadInfo)
|
||||||
|
di.__setstate__(state)
|
||||||
|
self.assertEqual(di.download_type, "captions")
|
||||||
|
self.assertEqual(di.format, "vtt")
|
||||||
|
|
||||||
|
def test_migrates_best_ios(self):
|
||||||
|
state = self._base_state(
|
||||||
|
format="any", quality="best_ios", video_codec="auto"
|
||||||
|
)
|
||||||
|
di = DownloadInfo.__new__(DownloadInfo)
|
||||||
|
di.__setstate__(state)
|
||||||
|
self.assertEqual(di.format, "ios")
|
||||||
|
self.assertEqual(di.quality, "best")
|
||||||
|
|
||||||
|
def test_migrates_quality_audio(self):
|
||||||
|
state = self._base_state(format="mp4", quality="audio", video_codec="h264")
|
||||||
|
di = DownloadInfo.__new__(DownloadInfo)
|
||||||
|
di.__setstate__(state)
|
||||||
|
self.assertEqual(di.download_type, "audio")
|
||||||
|
self.assertEqual(di.format, "m4a")
|
||||||
|
|
||||||
|
def test_new_state_has_subtitle_files(self):
|
||||||
|
state = self._base_state(
|
||||||
|
download_type="video",
|
||||||
|
codec="auto",
|
||||||
|
format="any",
|
||||||
|
quality="best",
|
||||||
|
)
|
||||||
|
di = DownloadInfo.__new__(DownloadInfo)
|
||||||
|
di.__setstate__(state)
|
||||||
|
self.assertEqual(di.subtitle_files, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -15,4 +15,13 @@ dependencies = [
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pylint",
|
"pylint",
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-aiohttp>=1.0",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["app/tests"]
|
||||||
|
pythonpath = [".", "app"]
|
||||||
|
addopts = "-v"
|
||||||
|
|||||||
+12
-12
@@ -23,14 +23,14 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.4",
|
"@angular/animations": "^21.2.5",
|
||||||
"@angular/common": "^21.2.4",
|
"@angular/common": "^21.2.5",
|
||||||
"@angular/compiler": "^21.2.4",
|
"@angular/compiler": "^21.2.5",
|
||||||
"@angular/core": "^21.2.4",
|
"@angular/core": "^21.2.5",
|
||||||
"@angular/forms": "^21.2.4",
|
"@angular/forms": "^21.2.5",
|
||||||
"@angular/platform-browser": "^21.2.4",
|
"@angular/platform-browser": "^21.2.5",
|
||||||
"@angular/platform-browser-dynamic": "^21.2.4",
|
"@angular/platform-browser-dynamic": "^21.2.5",
|
||||||
"@angular/service-worker": "^21.2.4",
|
"@angular/service-worker": "^21.2.5",
|
||||||
"@fortawesome/angular-fontawesome": "~4.0.0",
|
"@fortawesome/angular-fontawesome": "~4.0.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
"@fortawesome/free-brands-svg-icons": "^7.2.0",
|
||||||
@@ -48,10 +48,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-eslint/builder": "21.1.0",
|
"@angular-eslint/builder": "21.1.0",
|
||||||
"@angular/build": "^21.2.2",
|
"@angular/build": "^21.2.3",
|
||||||
"@angular/cli": "^21.2.2",
|
"@angular/cli": "^21.2.3",
|
||||||
"@angular/compiler-cli": "^21.2.4",
|
"@angular/compiler-cli": "^21.2.5",
|
||||||
"@angular/localize": "^21.2.4",
|
"@angular/localize": "^21.2.5",
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"angular-eslint": "21.1.0",
|
"angular-eslint": "21.1.0",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
|
|||||||
Generated
+313
-337
File diff suppressed because it is too large
Load Diff
+12
-17
@@ -1,24 +1,20 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
|
||||||
vi.hoisted(() => {
|
|
||||||
Object.defineProperty(window, "matchMedia", {
|
|
||||||
writable: true,
|
|
||||||
enumerable: true,
|
|
||||||
value: vi.fn().mockImplementation((query) => ({
|
|
||||||
matches: false,
|
|
||||||
media: query,
|
|
||||||
onchange: null,
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
removeEventListener: vi.fn(),
|
|
||||||
dispatchEvent: vi.fn(),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
@@ -29,5 +25,4 @@ describe('App', () => {
|
|||||||
const app = fixture.componentInstance;
|
const app = fixture.componentInstance;
|
||||||
expect(app).toBeTruthy();
|
expect(app).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||||
|
import { Checkable } from '../interfaces';
|
||||||
|
|
||||||
|
describe('SelectAllCheckboxComponent', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [SelectAllCheckboxComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicked sets checked on all list items', () => {
|
||||||
|
const fixture = TestBed.createComponent(SelectAllCheckboxComponent);
|
||||||
|
const list = new Map<string, Checkable>();
|
||||||
|
list.set('u1', { checked: false });
|
||||||
|
fixture.componentRef.setInput('id', 'queue');
|
||||||
|
fixture.componentRef.setInput('list', list);
|
||||||
|
fixture.componentInstance.selected = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
fixture.componentInstance.clicked();
|
||||||
|
expect(list.get('u1')?.checked).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { SelectAllCheckboxComponent } from './master-checkbox.component';
|
||||||
|
import { ItemCheckboxComponent } from './slave-checkbox.component';
|
||||||
|
|
||||||
|
describe('ItemCheckboxComponent', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ItemCheckboxComponent, SelectAllCheckboxComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates with master and checkable inputs', () => {
|
||||||
|
const masterFixture = TestBed.createComponent(SelectAllCheckboxComponent);
|
||||||
|
masterFixture.componentRef.setInput('id', 'q');
|
||||||
|
masterFixture.componentRef.setInput('list', new Map());
|
||||||
|
masterFixture.detectChanges();
|
||||||
|
|
||||||
|
const itemFixture = TestBed.createComponent(ItemCheckboxComponent);
|
||||||
|
itemFixture.componentRef.setInput('id', 'row1');
|
||||||
|
itemFixture.componentRef.setInput('master', masterFixture.componentInstance);
|
||||||
|
itemFixture.componentRef.setInput('checkable', { checked: false });
|
||||||
|
itemFixture.detectChanges();
|
||||||
|
expect(itemFixture.componentInstance).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { EtaPipe } from './eta.pipe';
|
||||||
|
|
||||||
|
describe('EtaPipe', () => {
|
||||||
|
it('returns null for null input', () => {
|
||||||
|
const pipe = new EtaPipe();
|
||||||
|
expect(pipe.transform(null as unknown as number)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats seconds under one minute', () => {
|
||||||
|
const pipe = new EtaPipe();
|
||||||
|
expect(pipe.transform(0)).toBe('0s');
|
||||||
|
expect(pipe.transform(59)).toBe('59s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats minutes and seconds', () => {
|
||||||
|
const pipe = new EtaPipe();
|
||||||
|
expect(pipe.transform(60)).toBe('1m 0s');
|
||||||
|
expect(pipe.transform(90)).toBe('1m 30s');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats hours', () => {
|
||||||
|
const pipe = new EtaPipe();
|
||||||
|
expect(pipe.transform(3600)).toBe('1h 0m 0s');
|
||||||
|
expect(pipe.transform(3661)).toBe('1h 1m 1s');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { FileSizePipe } from './file-size.pipe';
|
||||||
|
|
||||||
|
describe('FileSizePipe', () => {
|
||||||
|
it('returns 0 Bytes for zero or NaN', () => {
|
||||||
|
const pipe = new FileSizePipe();
|
||||||
|
expect(pipe.transform(0)).toBe('0 Bytes');
|
||||||
|
expect(pipe.transform(Number.NaN)).toBe('0 Bytes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats bytes and larger units', () => {
|
||||||
|
const pipe = new FileSizePipe();
|
||||||
|
expect(pipe.transform(500)).toContain('Bytes');
|
||||||
|
expect(pipe.transform(1000)).toContain('KB');
|
||||||
|
expect(pipe.transform(1000 * 1000)).toContain('MB');
|
||||||
|
expect(pipe.transform(1000 ** 3)).toContain('GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles boundaries between units', () => {
|
||||||
|
const pipe = new FileSizePipe();
|
||||||
|
expect(pipe.transform(999)).toContain('Bytes');
|
||||||
|
expect(pipe.transform(1000)).toContain('KB');
|
||||||
|
expect(pipe.transform(1001)).toContain('KB');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,4 +12,10 @@ describe('SpeedPipe', () => {
|
|||||||
expect(pipe.transform(1024)).toBe('1 KB/s');
|
expect(pipe.transform(1024)).toBe('1 KB/s');
|
||||||
expect(pipe.transform(1536)).toBe('1.5 KB/s');
|
expect(pipe.transform(1536)).toBe('1.5 KB/s');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('formats MB/s and GB/s', () => {
|
||||||
|
const pipe = new SpeedPipe();
|
||||||
|
expect(pipe.transform(1024 * 1024)).toBe('1 MB/s');
|
||||||
|
expect(pipe.transform(1024 * 1024 * 1024)).toBe('1 GB/s');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { DownloadsService, AddDownloadPayload } from './downloads.service';
|
||||||
|
import { MeTubeSocket } from './metube-socket.service';
|
||||||
|
import { Download } from '../interfaces';
|
||||||
|
|
||||||
|
class MeTubeSocketStub {
|
||||||
|
private subjects: Record<string, Subject<string>> = {};
|
||||||
|
|
||||||
|
fromEvent(event: string) {
|
||||||
|
if (!this.subjects[event]) {
|
||||||
|
this.subjects[event] = new Subject<string>();
|
||||||
|
}
|
||||||
|
return this.subjects[event].asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, data: string) {
|
||||||
|
if (!this.subjects[event]) {
|
||||||
|
this.subjects[event] = new Subject<string>();
|
||||||
|
}
|
||||||
|
this.subjects[event].next(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function basePayload(): AddDownloadPayload {
|
||||||
|
return {
|
||||||
|
url: 'https://example.com/v',
|
||||||
|
downloadType: 'video',
|
||||||
|
codec: 'auto',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
customNamePrefix: '',
|
||||||
|
playlistItemLimit: 0,
|
||||||
|
autoStart: true,
|
||||||
|
splitByChapters: false,
|
||||||
|
chapterTemplate: '',
|
||||||
|
subtitleLanguage: 'en',
|
||||||
|
subtitleMode: 'prefer_manual',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DownloadsService', () => {
|
||||||
|
let socket: MeTubeSocketStub;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let service: DownloadsService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
socket = new MeTubeSocketStub();
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
DownloadsService,
|
||||||
|
provideHttpClient(),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{ provide: MeTubeSocket, useValue: socket },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
service = TestBed.inject(DownloadsService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('add() posts snake_case fields matching backend', () => {
|
||||||
|
service.add(basePayload()).subscribe();
|
||||||
|
const req = httpMock.expectOne('add');
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
expect(req.request.body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: 'https://example.com/v',
|
||||||
|
download_type: 'video',
|
||||||
|
codec: 'auto',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
auto_start: true,
|
||||||
|
split_by_chapters: false,
|
||||||
|
chapter_template: '',
|
||||||
|
subtitle_language: 'en',
|
||||||
|
subtitle_mode: 'prefer_manual',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
req.flush({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancelAdd posts to cancel-add', () => {
|
||||||
|
service.cancelAdd().subscribe();
|
||||||
|
const req = httpMock.expectOne('cancel-add');
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
req.flush({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startById posts ids', () => {
|
||||||
|
service.startById(['a', 'b']).subscribe();
|
||||||
|
const req = httpMock.expectOne('start');
|
||||||
|
expect(req.request.body).toEqual({ ids: ['a', 'b'] });
|
||||||
|
req.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delById marks items deleting and posts delete', () => {
|
||||||
|
const dl: Download = {
|
||||||
|
id: '1',
|
||||||
|
title: 't',
|
||||||
|
url: 'u1',
|
||||||
|
download_type: 'video',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status: 'finished',
|
||||||
|
msg: '',
|
||||||
|
percent: 0,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: false,
|
||||||
|
deleting: false,
|
||||||
|
};
|
||||||
|
service.queue.set('u1', dl);
|
||||||
|
service.delById('queue', ['u1']).subscribe();
|
||||||
|
expect(dl.deleting).toBe(true);
|
||||||
|
const req = httpMock.expectOne('delete');
|
||||||
|
expect(req.request.body).toEqual({ where: 'queue', ids: ['u1'] });
|
||||||
|
req.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handleHTTPError extracts msg from object body', async () => {
|
||||||
|
const err = new HttpErrorResponse({
|
||||||
|
error: { msg: 'bad' },
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
const res = await new Promise((resolve) => {
|
||||||
|
service.handleHTTPError(err).subscribe(resolve);
|
||||||
|
});
|
||||||
|
expect((res as { status: string }).status).toBe('error');
|
||||||
|
expect((res as { msg?: string }).msg).toBe('bad');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('socket all updates queue and done', () => {
|
||||||
|
const row: Download = {
|
||||||
|
id: '1',
|
||||||
|
title: 't',
|
||||||
|
url: 'u1',
|
||||||
|
download_type: 'video',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status: 'pending',
|
||||||
|
msg: '',
|
||||||
|
percent: 0,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: false,
|
||||||
|
};
|
||||||
|
const q: [string, Download][] = [['u1', row]];
|
||||||
|
const d: [string, Download][] = [];
|
||||||
|
socket.emit('all', JSON.stringify([q, d]));
|
||||||
|
expect(service.loading).toBe(false);
|
||||||
|
expect(service.queue.has('u1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('socket updated preserves checked and deleting', () => {
|
||||||
|
service.queue.set('u1', {
|
||||||
|
id: '1',
|
||||||
|
title: 't',
|
||||||
|
url: 'u1',
|
||||||
|
download_type: 'video',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status: 'pending',
|
||||||
|
msg: '',
|
||||||
|
percent: 0,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: true,
|
||||||
|
deleting: true,
|
||||||
|
});
|
||||||
|
socket.emit(
|
||||||
|
'updated',
|
||||||
|
JSON.stringify({ url: 'u1', title: 't', status: 'downloading' }),
|
||||||
|
);
|
||||||
|
const updated = service.queue.get('u1');
|
||||||
|
expect(updated?.checked).toBe(true);
|
||||||
|
expect(updated?.deleting).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('socket completed moves entry to done', () => {
|
||||||
|
service.queue.set('u1', {
|
||||||
|
id: '1',
|
||||||
|
title: 't',
|
||||||
|
url: 'u1',
|
||||||
|
download_type: 'video',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status: 'pending',
|
||||||
|
msg: '',
|
||||||
|
percent: 0,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
socket.emit('completed', JSON.stringify({ url: 'u1', title: 't', status: 'finished' }));
|
||||||
|
expect(service.queue.has('u1')).toBe(false);
|
||||||
|
expect(service.done.has('u1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('socket canceled removes from queue', () => {
|
||||||
|
service.queue.set('u1', {
|
||||||
|
id: '1',
|
||||||
|
title: 't',
|
||||||
|
url: 'u1',
|
||||||
|
download_type: 'video',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status: 'pending',
|
||||||
|
msg: '',
|
||||||
|
percent: 0,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
socket.emit('canceled', JSON.stringify('u1'));
|
||||||
|
expect(service.queue.has('u1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('socket cleared removes from done', () => {
|
||||||
|
service.done.set('u1', {
|
||||||
|
id: '1',
|
||||||
|
title: 't',
|
||||||
|
url: 'u1',
|
||||||
|
download_type: 'video',
|
||||||
|
quality: 'best',
|
||||||
|
format: 'any',
|
||||||
|
folder: '',
|
||||||
|
custom_name_prefix: '',
|
||||||
|
playlist_item_limit: 0,
|
||||||
|
status: 'finished',
|
||||||
|
msg: '',
|
||||||
|
percent: 0,
|
||||||
|
speed: 0,
|
||||||
|
eta: 0,
|
||||||
|
filename: '',
|
||||||
|
checked: false,
|
||||||
|
});
|
||||||
|
socket.emit('cleared', JSON.stringify('u1'));
|
||||||
|
expect(service.done.has('u1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('socket configuration updates configuration', () => {
|
||||||
|
socket.emit('configuration', JSON.stringify({ CUSTOM_DIRS: true }));
|
||||||
|
expect(service.configuration['CUSTOM_DIRS']).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('socket custom_dirs updates customDirs', () => {
|
||||||
|
socket.emit('custom_dirs', JSON.stringify({ download_dir: [''] }));
|
||||||
|
expect(service.customDirs['download_dir']).toEqual(['']);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -114,11 +114,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "attrs"
|
name = "attrs"
|
||||||
version = "25.4.0"
|
version = "26.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -324,15 +324,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deno"
|
name = "deno"
|
||||||
version = "2.7.5"
|
version = "2.7.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/31/8bbaf3fb6a41929ae161be0b2a79b2747b5e5490811573ef60af7e3aeac3/deno-2.7.5.tar.gz", hash = "sha256:50635e0462697fa6e79d90bcacbe98e19f785e604c0e5061754de89b3668af83", size = 8166, upload-time = "2026-03-11T12:48:44.286Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/87/b4/e893908807648b8c499a085cf47c9ca6418a060b0f12e73f128478ada409/deno-2.7.7.tar.gz", hash = "sha256:5798bba73f89ddf50fa33044c8a44fe708fb19ab77b3ef98d02f4124e760fb65", size = 8166, upload-time = "2026-03-19T13:57:09.905Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/15/47c4b8da4e1b312ab14a2517e3f484c4d67a879cb5099cb6c33b8ce00c8c/deno-2.7.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29cb89cdaea5f36133841fb4da058b1c6cb70d117ebfc7a24c717747b58e8503", size = 46641593, upload-time = "2026-03-11T12:48:16.589Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/08/362f834c64798033ca56a02a1a4e8feca653b9b767aab4a854069ba8c801/deno-2.7.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:70be65294ee575b2e5ee66b587c459500984b1df17505fd6f5e7bffad402de0f", size = 46934365, upload-time = "2026-03-19T13:56:54.324Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/3a/c3f8842b7499ff3faeb7508711a82b736d3a4c6e0ffb359191386bcf539d/deno-2.7.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6456980341e97e4eb88e0c560fa57cd1b5f732e0eaadccc6c47d5ada73a71ff3", size = 43537874, upload-time = "2026-03-11T12:48:21.958Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/3f/cdbe9daa33e997f26610ee7f554e51ba2c8fd7a18abcbc9c6069e6386164/deno-2.7.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:65641b2dd299e3a4aae4f080d4e32d632bbcf44d77f72f97f61aa7b68ded4747", size = 43831345, upload-time = "2026-03-19T13:56:57.565Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/a2/53a013ba3509648582748678d5c6980210a45e0913934f91bfe1ec237e07/deno-2.7.5-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:fdc1e647a06ef792643237c030f45295692b0abc05d5bc9894fb11fd70876953", size = 47265090, upload-time = "2026-03-11T12:48:26.819Z" },
|
{ url = "https://files.pythonhosted.org/packages/25/e7/5f63b2a64fc2f7a7ce6c73e9e847c41034283890e6edec0b2791518b7edd/deno-2.7.7-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:cc90d761472df285a8709483d3615fbd2faf4bbc162530196b5a112e4a561016", size = 47571993, upload-time = "2026-03-19T13:57:00.833Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/85/88c76daa72575f7229bb94191f15f4771f0614227bf8467bfe06e051f4ab/deno-2.7.5-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:c15e6b8ccf5f0808cd5ba243ea4eea7d8d78f6fdff228f5c6c85b96ba286bd3c", size = 49262188, upload-time = "2026-03-11T12:48:32.125Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/f2/68f4bb53de09970744f905628cff011bd6964f2f00f263140dcc9412a7b5/deno-2.7.7-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ced70363e30a7e3f27f614ffd46d69ccf1dd57633f0df6a3c6375ed2c803aa7", size = 49577613, upload-time = "2026-03-19T13:57:03.766Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/5e/501a92ef93d6d46ed8a1a8c03cff8bcbccbc06c1f59b163113ff09cd23cf/deno-2.7.5-py3-none-win_amd64.whl", hash = "sha256:3e3d06006ee39901dd23068c4a501a4a524fb71c323e22503b1b2ddf236da463", size = 48481169, upload-time = "2026-03-11T12:48:38.684Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/db/2fa6239c0d4df46ef6f3f43d55133aeda6cdd6668c6044d275548a95da24/deno-2.7.7-py3-none-win_amd64.whl", hash = "sha256:e614f666c169ade86a3a089a15a32b9a2002d1ad3294f1fbc8a1bd50c2bac4ab", size = 48802184, upload-time = "2026-03-19T13:57:07.328Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -435,6 +435,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "isort"
|
name = "isort"
|
||||||
version = "8.0.1"
|
version = "8.0.1"
|
||||||
@@ -469,6 +478,9 @@ dependencies = [
|
|||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pylint" },
|
{ name = "pylint" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-aiohttp" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -482,7 +494,12 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [{ name = "pylint" }]
|
dev = [
|
||||||
|
{ name = "pylint" },
|
||||||
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
|
{ name = "pytest-aiohttp", specifier = ">=1.0" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multidict"
|
name = "multidict"
|
||||||
@@ -574,6 +591,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "4.9.4"
|
version = "4.9.4"
|
||||||
@@ -583,6 +609,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -691,6 +726,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pylint"
|
name = "pylint"
|
||||||
version = "4.0.5"
|
version = "4.0.5"
|
||||||
@@ -709,6 +753,48 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-aiohttp"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/4b/d326890c153f2c4ce1bf45d07683c08c10a1766058a22934620bc6ac6592/pytest_aiohttp-1.1.0.tar.gz", hash = "sha256:147de8cb164f3fc9d7196967f109ab3c0b93ea3463ab50631e56438eab7b5adc", size = 12842, upload-time = "2025-01-23T12:44:04.465Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/0f/e6af71c02e0f1098eaf7d2dbf3ffdf0a69fc1e0ef174f96af05cef161f1b/pytest_aiohttp-1.1.0-py3-none-any.whl", hash = "sha256:f39a11693a0dce08dd6c542d241e199dd8047a6e6596b2bcfa60d373f143456d", size = 8932, upload-time = "2025-01-23T12:44:03.27Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-engineio"
|
name = "python-engineio"
|
||||||
version = "4.13.1"
|
version = "4.13.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user