diff --git a/.vscode/settings.json b/.vscode/settings.json index 942fa513..0e1aabb2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,6 +22,7 @@ "asyncio", "attl", "autonumber", + "autouse", "bgutil", "bgutilhttp", "bilibili", @@ -42,6 +43,7 @@ "dateparser", "daterange", "defusedxml", + "delenv", "dlfields", "dotdot", "dotenv", @@ -56,6 +58,7 @@ "euuo", "eventbus", "excepthook", + "falsey", "faststart", "Fetc", "finaldir", diff --git a/app/library/Subtitle.py b/app/library/Subtitle.py index e78f1476..2cda849e 100644 --- a/app/library/Subtitle.py +++ b/app/library/Subtitle.py @@ -8,13 +8,13 @@ from pysubs2.time import ms_to_times from .Utils import ALLOWED_SUBS_EXTENSIONS -LOG = logging.getLogger("player.subtitle") +LOG: logging.Logger = logging.getLogger("player.subtitle") def ms_to_timestamp(ms: int) -> str: ms = max(0, ms) h, m, s, ms = ms_to_times(ms) - cs = ms // 10 + cs: int = ms // 10 return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}" @@ -24,7 +24,7 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp class Subtitle: async def make(self, file: Path) -> str: if file.suffix not in ALLOWED_SUBS_EXTENSIONS: - msg = f"File '{file}' subtitle type is not supported." + msg: str = f"File '{file}' subtitle type is not supported." raise Exception(msg) if file.suffix == ".vtt": diff --git a/app/tests/test_backgroundworker.py b/app/tests/test_backgroundworker.py new file mode 100644 index 00000000..e48a1a28 --- /dev/null +++ b/app/tests/test_backgroundworker.py @@ -0,0 +1,86 @@ +import threading +from typing import Any + +from aiohttp import web + +from app.library.BackgroundWorker import BackgroundWorker + + +class TestBackgroundWorker: + def setup_method(self) -> None: + BackgroundWorker._reset_singleton() + + def teardown_method(self) -> None: + # Attempt to stop any running worker thread + try: + worker = BackgroundWorker() + if worker.thread and worker.thread.is_alive(): + worker.running = False + worker.queue.put((BackgroundWorker, (), {})) + worker.thread.join(timeout=2) + except Exception: + pass + BackgroundWorker._reset_singleton() + + def test_attach_starts_and_shutdown_stops(self) -> None: + app = web.Application() + worker = BackgroundWorker() + + worker.attach(app) + assert worker.thread is not None + assert worker.thread.is_alive() is True + + # Shutdown should stop the worker thread + # on_shutdown is async; call via event loop runner + # Using a small helper to run the coroutine + import asyncio + + asyncio.get_event_loop().run_until_complete(worker.on_shutdown(app)) + + # Give a short moment for the background thread to exit + worker.thread.join(timeout=2) + assert worker.thread.is_alive() is False + + def test_submit_executes_sync_function(self) -> None: + app = web.Application() + worker = BackgroundWorker() + worker.attach(app) + + done = threading.Event() + received: dict[str, Any] = {} + + def job(x: int, y: int) -> None: + received["sum"] = x + y + done.set() + + worker.submit(job, 2, 3) + + assert done.wait(timeout=2.0) is True + assert received["sum"] == 5 + + # Cleanup + import asyncio + + asyncio.get_event_loop().run_until_complete(worker.on_shutdown(app)) + + def test_submit_executes_async_coroutine(self) -> None: + app = web.Application() + worker = BackgroundWorker() + worker.attach(app) + + done = threading.Event() + + async def coro_task(flag: threading.Event) -> None: + # Simulate a small async operation + await asyncio.sleep(0) + flag.set() + + # Submit coroutine factory + import asyncio + + worker.submit(coro_task, done) + + assert done.wait(timeout=2.0) is True + + # Cleanup + asyncio.get_event_loop().run_until_complete(worker.on_shutdown(app)) diff --git a/app/tests/test_datastore.py b/app/tests/test_datastore.py new file mode 100644 index 00000000..acfcc09e --- /dev/null +++ b/app/tests/test_datastore.py @@ -0,0 +1,157 @@ +import json +import sqlite3 +from dataclasses import asdict +from datetime import UTC, datetime +from email.utils import formatdate + +import pytest + +from app.library.DataStore import DataStore, StoreType +from app.library.ItemDTO import ItemDTO + + +class StubDownload: + def __init__(self, info: ItemDTO, started: bool = False, cancelled: bool = False): + self.info = info + self._started = started + self._cancelled = cancelled + + def started(self) -> bool: + return self._started + + def is_cancelled(self) -> bool: + return self._cancelled + + +def make_conn() -> sqlite3.Connection: + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute( + "CREATE TABLE history (id TEXT PRIMARY KEY, type TEXT, url TEXT, data TEXT, created_at TEXT)" + ) + return conn + + +def make_item(id: str, url: str = "http://u", title: str = "t", folder: str = "f") -> ItemDTO: + return ItemDTO(id=id, title=title, url=url, folder=folder) + + +class TestStoreType: + def test_all_and_from_value_and_str(self) -> None: + assert set(StoreType.all()) == {"done", "queue"} + assert StoreType.from_value("queue") is StoreType.QUEUE + assert StoreType.from_value("done") is StoreType.HISTORY + assert str(StoreType.QUEUE) == "queue" + with pytest.raises(ValueError, match="Invalid StoreType value"): + StoreType.from_value("invalid") + + +class TestDataStore: + def test_saved_items_parses_rows(self) -> None: + conn = make_conn() + # Prepare a stored item JSON (without _id, it will be set from the row) + dto = make_item(id="ignore", url="http://x", title="Title", folder="F") + data = asdict(dto) + data.pop("_id", None) + created = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC) + conn.execute( + "INSERT INTO history (id, type, url, data, created_at) VALUES (?, ?, ?, ?, ?)", + ("abc", "queue", "http://x", json.dumps(data), created.strftime("%Y-%m-%d %H:%M:%S")), + ) + store = DataStore(StoreType.QUEUE, conn) + + items = store.saved_items() + assert len(items) == 1 + key, item = items[0] + assert key == "abc" + assert item._id == "abc" + assert item.url == "http://x" + assert isinstance(item.datetime, str) + assert item.datetime == formatdate(created.timestamp()) + + def test_put_and_delete_persist(self) -> None: + conn = make_conn() + store = DataStore(StoreType.QUEUE, conn) + + item = make_item(id="vid1") + d = StubDownload(info=item) + + ret = store.put(d) + assert ret is store._dict[item._id] + + # Verify row written; JSON should not contain datetime field + row = conn.execute("SELECT * FROM history WHERE id=?", (item._id,)).fetchone() + assert row is not None + assert row["type"] == "queue" + assert row["url"] == item.url + assert '"datetime"' not in row["data"] + + # Delete and ensure removal + store.delete(item._id) + row2 = conn.execute("SELECT * FROM history WHERE id=?", (item._id,)).fetchone() + assert row2 is None + + def test_exists_and_get(self) -> None: + conn = make_conn() + store = DataStore(StoreType.QUEUE, conn) + + item = make_item(id="a1", url="http://u1") + d = StubDownload(info=item) + store.put(d) + + assert store.exists(key=item._id) is True + assert store.exists(url=item.url) is True + with pytest.raises(KeyError): + store.exists() + + got = store.get(key=item._id) + assert got.info._id == item._id + got2 = store.get(url=item.url) + assert got2.info.url == item.url + with pytest.raises(KeyError): + store.get() + with pytest.raises(KeyError): + store.get(key="missing") + + def test_next_and_empty(self) -> None: + conn = make_conn() + store = DataStore(StoreType.QUEUE, conn) + + assert store.empty() is True + d1 = StubDownload(info=make_item(id="x1")) + d2 = StubDownload(info=make_item(id="x2")) + store.put(d1) + store.put(d2) + assert store.empty() is False + first_key, first_val = store.next() + assert first_key == d1.info._id + assert first_val.info._id == d1.info._id + + def test_has_downloads_and_get_next_download(self) -> None: + conn = make_conn() + store = DataStore(StoreType.QUEUE, conn) + + # One non-auto-start, one started, one cancelled, and one eligible + i1 = make_item(id="n1") + i1.auto_start = False + i2 = make_item(id="s1") + i3 = make_item(id="c1") + i4 = make_item(id="ok1") + + store.put(StubDownload(info=i1, started=False)) + store.put(StubDownload(info=i2, started=True)) + store.put(StubDownload(info=i3, started=False, cancelled=True)) + store.put(StubDownload(info=i4, started=False)) + + assert store.has_downloads() is True + nxt = store.get_next_download() + assert isinstance(nxt, StubDownload) + assert nxt.info._id == i4._id + + @pytest.mark.asyncio + async def test_test_method_executes_query(self) -> None: + conn = make_conn() + store = DataStore(StoreType.QUEUE, conn) + # Should not raise + ok = await store.test() + assert ok is True diff --git a/app/tests/test_download.py b/app/tests/test_download.py new file mode 100644 index 00000000..00530df8 --- /dev/null +++ b/app/tests/test_download.py @@ -0,0 +1,376 @@ +import logging +from pathlib import Path +from typing import Any + +import pytest + +from app.library.Download import Download, NestedLogger, Terminator +from app.library.ItemDTO import ItemDTO + + +class CaptureHandler(logging.Handler): + def __init__(self) -> None: + super().__init__() + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + +def make_item(id: str = "id1", title: str = "T", url: str = "http://u", folder: str = "f") -> ItemDTO: + return ItemDTO(id=id, title=title, url=url, folder=folder) + + +class DummyQueue: + def __init__(self) -> None: + self.items: list[Any] = [] + + def put(self, obj: Any) -> None: + self.items.append(obj) + + def get(self) -> Any: + if not self.items: + return None + return self.items.pop(0) + + +class TestNestedLogger: + def test_debug_maps_levels_and_strips_prefix(self) -> None: + logger = logging.getLogger("nl_test") + logger.setLevel(logging.DEBUG) + cap = CaptureHandler() + # Remove existing handlers to avoid duplicates + for h in list(logger.handlers): + logger.removeHandler(h) + logger.addHandler(cap) + + nl = NestedLogger(logger) + nl.debug("[debug] detail") + nl.debug("[download] progress") + nl.debug("[info] info message") + + # Two DEBUG, one INFO + levels = [r.levelno for r in cap.records] + assert levels.count(logging.DEBUG) == 2 + assert levels.count(logging.INFO) == 1 + msgs = [r.getMessage() for r in cap.records] + assert "[debug]" not in msgs[0] + # [download] prefix is not stripped by NestedLogger + assert msgs[1] == "[download] progress" + assert msgs[2] == "info message" + + +class TestDownloadHooks: + @pytest.fixture(autouse=True) + def cfg_and_bus(self, monkeypatch: pytest.MonkeyPatch): + # Minimal Config stub + class Cfg: + debug = False + ytdlp_debug = False + max_workers = 1 + temp_keep = False + temp_disabled = True + download_info_expires = 3600 + + @staticmethod + def get_instance(): + return Cfg + + monkeypatch.setattr("app.library.Download.Config", Cfg) + # EventBus.get_instance is used during __init__ and start, we don't hit start here + class EB: + @staticmethod + def get_instance(): + return EB + + @staticmethod + def emit(*_args, **_kwargs): + return None + + monkeypatch.setattr("app.library.Download.EventBus", EB) + + def test_progress_hook_filters_fields(self) -> None: + d = Download(make_item()) + q = DummyQueue() + d.status_queue = q + + payload = { + "tmpfilename": "t", + "filename": "f", + "status": "downloading", + "msg": "m", + "total_bytes": 10, + "total_bytes_estimate": 12, + "downloaded_bytes": 5, + "speed": 1, + "eta": 2, + "other": "x", + } + d._progress_hook(payload) + assert len(q.items) == 1 + ev = q.items[0] + assert ev["id"] == d.id + assert ev["action"] == "progress" + # ensure only whitelisted keys included + assert "other" not in ev + for k in ("tmpfilename", "filename", "status", "msg", "total_bytes", "total_bytes_estimate", "downloaded_bytes", "speed", "eta"): + assert k in ev + + def test_postprocessor_hook_movefiles_sets_final_name(self, tmp_path: Path) -> None: + d = Download(make_item()) + q = DummyQueue() + d.status_queue = q + + finaldir = tmp_path / "out" + finaldir.mkdir() + path = finaldir / "file.mp4" + # we don't need it to exist for this branch; hook doesn't check file existence + payload = { + "postprocessor": "MoveFiles", + "status": "finished", + "info_dict": {"__finaldir": str(finaldir), "filepath": str(path)}, + } + d._postprocessor_hook(payload) + assert len(q.items) == 1 + ev = q.items[0] + assert ev["action"] == "moved" + assert ev["status"] == "finished" + assert ev["final_name"].endswith("file.mp4") + + def test_post_hooks_pushes_filename(self) -> None: + d = Download(make_item()) + q = DummyQueue() + d.status_queue = q + d.post_hooks(None) + assert len(q.items) == 0 + d.post_hooks("name.ext") + assert len(q.items) == 1 + assert q.items[0]["filename"] == "name.ext" + + +class TestDownloadStale: + @pytest.fixture(autouse=True) + def cfg_and_bus(self, monkeypatch: pytest.MonkeyPatch): + class Cfg: + debug = False + ytdlp_debug = False + max_workers = 1 + temp_keep = False + temp_disabled = True + download_info_expires = 3600 + + @staticmethod + def get_instance(): + return Cfg + + monkeypatch.setattr("app.library.Download.Config", Cfg) + + class EB: + @staticmethod + def get_instance(): + return EB + + @staticmethod + def emit(*_args, **_kwargs): + return None + + monkeypatch.setattr("app.library.Download.EventBus", EB) + + def test_is_stale_conditions(self, monkeypatch: pytest.MonkeyPatch) -> None: + d = Download(make_item()) + # Auto-start disabled is never stale + d.info.auto_start = False + assert d.is_stale() is False + + d.info.auto_start = True + # Not started yet -> not stale + d.started_time = 0 + assert d.is_stale() is False + + # Less than 300 seconds elapsed -> not stale + d.started_time = 1000 + monkeypatch.setattr("time.time", lambda: 1200) + assert d.is_stale() is False + + # Not running after 300s -> stale + d.started_time = 0 + d.started_time = 1000 + monkeypatch.setattr("time.time", lambda: 1401) + monkeypatch.setattr("app.library.Download.Download.running", lambda _self: False) + d.info.status = "preparing" + assert d.is_stale() is True + + # Running but status stuck -> stale + monkeypatch.setattr("app.library.Download.Download.running", lambda _self: True) + d.info.status = "queued" + assert d.is_stale() is True + + # Running and in allowed statuses -> not stale + for s in ("finished", "error", "cancelled", "downloading", "postprocessing"): + d.info.status = s + assert d.is_stale() is False + + +@pytest.mark.asyncio +async def test_start_initializes_and_emits(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class Cfg: + debug = False + ytdlp_debug = False + max_workers = 1 + temp_keep = False + temp_disabled = False + download_info_expires = 3600 + + @staticmethod + def get_instance(): + return Cfg + + class _Mgr: + @staticmethod + def Queue(): # noqa: N802 + return DummyQueue() + + @staticmethod + def get_manager(): + return Cfg._Mgr + + monkeypatch.setattr("app.library.Download.Config", Cfg) + + events = [] + + class EB: + @staticmethod + def get_instance(): + return EB + + @staticmethod + def emit(event, data=None, **_kwargs): + events.append((event, data)) + + monkeypatch.setattr("app.library.Download.EventBus", EB) + + class FakeProc: + def __init__(self, name: str, target, *_args, **_kwargs): + self.name = name + self.target = target + self.started = False + + def start(self) -> None: + self.started = True + + def join(self) -> int: + return 0 + + monkeypatch.setattr("app.library.Download.multiprocessing.Process", FakeProc) + + item = make_item(id="start1") + d = Download(item) + d.temp_dir = str(tmp_path) + d.download_dir = str(tmp_path) + + await d.start() + + assert d.info.status == "preparing" + assert isinstance(d.status_queue, DummyQueue) + assert d.temp_path is not None + assert Path(d.temp_path).exists() + assert len(events) >= 1 + + +@pytest.mark.asyncio +async def test_progress_update_processes_statuses(monkeypatch: pytest.MonkeyPatch) -> None: + class Cfg: + debug = False + ytdlp_debug = False + max_workers = 1 + temp_keep = False + temp_disabled = True + download_info_expires = 3600 + + @staticmethod + def get_instance(): + return Cfg + + monkeypatch.setattr("app.library.Download.Config", Cfg) + + class EB: + @staticmethod + def get_instance(): + return EB + + @staticmethod + def emit(*_a, **_kw): + return None + + monkeypatch.setattr("app.library.Download.EventBus", EB) + + seen = [] + + async def spy(_self, status): + seen.append(status) + + d = Download(make_item(id="p1")) + d.status_queue = DummyQueue() + monkeypatch.setattr(Download, "_process_status_update", spy, raising=False) + d.status_queue.put({"id": d.id, "status": "downloading"}) + d.status_queue.put({"id": d.id, "status": "postprocessing"}) + d.status_queue.put(Terminator()) + + await d.progress_update() + + assert [s["status"] for s in seen] == ["downloading", "postprocessing"] + + +@pytest.mark.asyncio +async def test__process_status_update_finish_sets_fields(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class Cfg: + debug = False + ytdlp_debug = False + max_workers = 1 + temp_keep = False + temp_disabled = True + download_info_expires = 3600 + + @staticmethod + def get_instance(): + return Cfg + + monkeypatch.setattr("app.library.Download.Config", Cfg) + + class EB: + @staticmethod + def get_instance(): + return EB + + @staticmethod + def emit(*_a, **_kw): + return None + + monkeypatch.setattr("app.library.Download.EventBus", EB) + + class FF: + def has_video(self): + return True + + def has_audio(self): + return True + + metadata = {"duration": "7.9"} + + async def fake_ffprobe(_p): + return FF() + + monkeypatch.setattr("app.library.Download.ffprobe", fake_ffprobe) + + d = Download(make_item(id="f1")) + d.download_dir = str(tmp_path) + path = Path(tmp_path) / "file.bin" + path.write_bytes(b"x" * 10) + + await d._process_status_update({"id": d.id, "status": "finished", "final_name": str(path)}) + + assert d.info.file_size == 10 + assert d.info.extras["is_video"] is True + assert d.info.extras["is_audio"] is True + assert d.info.extras["duration"] == int(7.9) + assert isinstance(d.info.datetime, str) diff --git a/app/tests/test_itemdto.py b/app/tests/test_itemdto.py new file mode 100644 index 00000000..d777e251 --- /dev/null +++ b/app/tests/test_itemdto.py @@ -0,0 +1,167 @@ +import json +from unittest.mock import patch + +import pytest + +from app.library.ItemDTO import Item, ItemDTO + + +class TestItemFormatAndBasics: + @patch("app.library.Presets.Presets.get_instance") + def test_format_validates_and_normalizes(self, mock_presets_get): + # Preset exists and is not default => allowed + mock_presets_get.return_value.has.return_value = True + + data = { + "url": "dQw4w9WgXcQ", # 11-char YouTube ID + "preset": "custom", + "folder": "media", + "cookies": "abc", + "template": "%(title)s.%(ext)s", + "auto_start": False, + "extras": {"k": 1}, + "requeued": True, + "cli": "--embed-metadata", + } + with ( + patch("app.library.ItemDTO.Item._default_preset", return_value="default"), + patch("app.library.Utils.arg_converter") as mock_arg_conv, + ): + mock_arg_conv.return_value = None + item = Item.format(data) + + assert isinstance(item, Item) + # URL normalized to full YouTube URL + assert item.url.startswith("https://www.youtube.com/watch?v=") + assert item.preset == "custom" + assert item.folder == "media" + assert item.cookies == "abc" + assert item.template == "%(title)s.%(ext)s" + assert item.auto_start is False + assert item.extras == {"k": 1} + assert item.requeued is True + assert item.cli == "--embed-metadata" + + @patch("app.library.Presets.Presets.get_instance") + def test_format_raises_for_missing_url_and_invalid_preset(self, mock_presets_get): + # Missing url + with pytest.raises(ValueError, match="url param is required"): + Item.format({}) + + # Invalid preset name (not found) + mock_presets_get.return_value.has.return_value = False + with ( + patch("app.library.ItemDTO.Item._default_preset", return_value="default"), + pytest.raises(ValueError, match="Preset 'bad' does not exist"), + ): + Item.format({"url": "https://example.com", "preset": "bad"}) + + @patch("app.library.Utils.arg_converter") + def test_format_cli_parse_error(self, mock_arg_conv): + mock_arg_conv.side_effect = RuntimeError("bad cli") + with pytest.raises(ValueError, match="Failed to parse command options"): + Item.format({"url": "https://example.com", "cli": "--bad"}) + + def test_item_helpers(self): + item = Item(url="https://example.com", extras={"a": 1}, cli="--x") + assert item.has_extras() is True + assert item.has_cli() is True + assert item.get("url") == "https://example.com" + assert "url" in item.serialize() + assert json.loads(item.json())["url"] == "https://example.com" + + @patch("app.library.ItemDTO.get_archive_id") + def test_item_archive_id_and_is_archived(self, mock_get_id): + mock_get_id.return_value = {"archive_id": "x", "id": "x", "ie_key": "k"} + + # get_archive_id + item = Item(url="https://example.com") + assert item.get_archive_id() == "x" + + # is_archived uses archive_read through get_archive_file + ytdlp opts + with ( + patch("app.library.ItemDTO.YTDLPOpts") as mock_opts, + patch("app.library.ItemDTO.archive_read") as mock_read, + ): + mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value + mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value + mock_opts.get_instance.return_value.get_all.return_value = {"download_archive": "/tmp/archive.txt"} + mock_read.return_value = ["x"] + + assert item.is_archived() is True + + +class TestItemDTO: + @patch("app.library.ItemDTO.get_archive_id") + @patch("app.library.ItemDTO.YTDLPOpts") + @patch("app.library.ItemDTO.archive_read") + def test_post_init_sets_archive_flags(self, mock_read, mock_opts, mock_get_id): + # Setup archive id and archive file + mock_get_id.return_value = {"archive_id": "arch", "id": "arch", "ie_key": "YT"} + mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value + mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value + mock_opts.get_instance.return_value.get_all.return_value = {"download_archive": "/tmp/a.txt"} + mock_read.return_value = ["arch"] + + dto = ItemDTO(id="vid", title="t", url="u", folder="f") + + assert dto.archive_id == "arch" + assert dto._archive_file == "/tmp/a.txt" + assert dto.is_archivable is True + assert dto.is_archived is True + + @patch("app.library.ItemDTO.archive_read") + def test_serialize_triggers_archive_status_when_finished(self, mock_read): + # Given a finished item with archive info + dto = ItemDTO(id="vid", title="t", url="u", folder="f") + dto.archive_id = "arch" + dto._archive_file = "/tmp/a.txt" + dto.status = "finished" + mock_read.return_value = ["arch"] + + data = dto.serialize() + assert data["is_archived"] is True + # Removed fields must not be present + for key in ItemDTO.removed_fields(): + assert key not in data + + @patch("app.library.ItemDTO.YTDLPOpts") + def test_get_ytdlp_opts_uses_preset_and_cli(self, mock_opts): + mock_opts.get_instance.return_value.preset.return_value = mock_opts.get_instance.return_value + mock_opts.get_instance.return_value.add_cli.return_value = mock_opts.get_instance.return_value + + dto = ItemDTO(id="id", title="t", url="u", folder="f", preset="p", cli="--x") + opts = dto.get_ytdlp_opts() + + mock_opts.get_instance.assert_called_once() + mock_opts.get_instance.return_value.preset.assert_called_once_with(name="p") + mock_opts.get_instance.return_value.add_cli.assert_called_once() + assert opts is mock_opts.get_instance.return_value + + def test_name_and_ids(self): + dto = ItemDTO(id="abc", title="Title", url="u", folder="f") + assert dto.name() == 'id="abc", title="Title"' + assert isinstance(dto.get_id(), str) + + def test_archive_add_and_delete_paths(self): + dto = ItemDTO(id="id", title="t", url="u", folder="f") + # Precondition not met yet + assert dto.archive_add() is False + + # Set up to allow add + dto.archive_id = "arch" + dto._archive_file = "/tmp/a.txt" + dto.is_archivable = True + dto.is_archived = False + + with patch("app.library.ItemDTO.archive_add", return_value=True) as m_add: + ok = dto.archive_add() + assert ok is True + m_add.assert_called_once() + + # Delete requires is_archived True + dto.is_archived = True + with patch("app.library.ItemDTO.archive_delete") as m_del: + ok2 = dto.archive_delete() + assert ok2 is True + m_del.assert_called_once() diff --git a/app/tests/test_logwrapper.py b/app/tests/test_logwrapper.py new file mode 100644 index 00000000..9d8658f3 --- /dev/null +++ b/app/tests/test_logwrapper.py @@ -0,0 +1,98 @@ +import logging +from typing import Any + +import pytest + +from app.library.LogWrapper import LogWrapper + + +class CaptureHandler(logging.Handler): + def __init__(self) -> None: + super().__init__() + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + +def make_logger(name: str = "lw_test") -> tuple[logging.Logger, CaptureHandler]: + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG) + handler = CaptureHandler() + # Avoid duplicate handlers when tests run multiple times + for h in list(logger.handlers): + logger.removeHandler(h) + logger.addHandler(handler) + return logger, handler + + +class TestLogWrapper: + def test_add_target_type_validation(self) -> None: + lw = LogWrapper() + with pytest.raises(TypeError, match=r"Target must be a logging\.Logger instance or a callable"): + lw.add_target(123) # type: ignore[arg-type] + + def test_add_target_name_inference_and_custom(self) -> None: + lw = LogWrapper() + logger, _ = make_logger("one") + + # Name inferred from logger + lw.add_target(logger) + assert lw.targets[-1].name == "one" + assert lw.has_targets() is True + + # Name inferred from callable + def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None: # noqa: ARG001 + return None + + lw.add_target(sink) + assert lw.targets[-1].name == "sink" + + # Custom name overrides + lw.add_target(logger, name="custom") + assert lw.targets[-1].name == "custom" + + def test_level_filtering_and_dispatch(self) -> None: + lw = LogWrapper() + logger, cap = make_logger("cap") + calls: list[tuple[int, str, tuple, dict]] = [] + + def sink(level: int, msg: str, *args: Any, **kwargs: Any) -> None: + calls.append((level, msg, args, kwargs)) + + # Logger target at INFO, callable target at WARNING + lw.add_target(logger, level=logging.INFO) + lw.add_target(sink, level=logging.WARNING) + + # DEBUG should hit none + lw.debug("d1") + assert len(cap.records) == 0 + assert len(calls) == 0 + + # INFO hits logger only + lw.info("hello %s", "X") + assert len(cap.records) == 1 + assert cap.records[0].levelno == logging.INFO + assert cap.records[0].getMessage() == "hello X" + assert len(calls) == 0 + + # WARNING hits both + lw.warning("warn %s", "Y", extra={"k": 1}) + assert len(cap.records) == 2 + assert cap.records[1].levelno == logging.WARNING + assert cap.records[1].getMessage() == "warn Y" + assert len(calls) == 1 + lvl, msg, args, kwargs = calls[0] + assert lvl == logging.WARNING + assert msg == "warn %s" + assert args == ("Y",) + assert "extra" in kwargs + assert kwargs["extra"] == {"k": 1} + + # ERROR still hits both; CRITICAL too + lw.error("err") + lw.critical("boom") + assert any(r.levelno == logging.ERROR for r in cap.records) + assert any(r.levelno == logging.CRITICAL for r in cap.records) + assert any(c[0] == logging.ERROR for c in calls) + assert any(c[0] == logging.CRITICAL for c in calls) diff --git a/app/tests/test_m3u8.py b/app/tests/test_m3u8.py new file mode 100644 index 00000000..c703b781 --- /dev/null +++ b/app/tests/test_m3u8.py @@ -0,0 +1,146 @@ +from pathlib import Path +from types import SimpleNamespace +from urllib.parse import quote + +import pytest + +from app.library.M3u8 import M3u8 +from app.library.Utils import StreamingError + + +class _Stream: + def __init__(self, v: bool, a: bool, codec: str): + self.codec_name = codec + self._v = v + self._a = a + + def is_video(self) -> bool: + return self._v + + def is_audio(self) -> bool: + return self._a + + +@pytest.mark.asyncio +async def test_make_stream_basic_ok_codecs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + base = tmp_path / "dl" + base.mkdir() + media = base / "dir with space" / "file.mp4" + media.parent.mkdir() + media.write_text("x") + + async def fake_ffprobe(_file: Path): + return SimpleNamespace( + metadata={"duration": "12"}, + streams=lambda: [ + _Stream(v=True, a=False, codec="h264"), + _Stream(v=False, a=True, codec="aac"), + ], + ) + + monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe) + + m3 = M3u8(download_path=base, url="http://host/") + out = await m3.make_stream(media) + lines = out.splitlines() + + # Headers + assert lines[0] == "#EXTM3U" + assert lines[1] == "#EXT-X-VERSION:3" + assert lines[2] == "#EXT-X-TARGETDURATION:6" + assert lines[3] == "#EXT-X-MEDIA-SEQUENCE:0" + assert lines[4] == "#EXT-X-PLAYLIST-TYPE:VOD" + + # Two segments: 0 (no params), 1 (sd present) + rel = quote(str(Path("dir with space/file.mp4"))) + assert lines[5] == "#EXTINF:6.000000," + assert lines[6] == f"http://host/api/player/segments/0/{rel}.ts" + + assert lines[7] == "#EXTINF:6.000000," + assert lines[8].startswith(f"http://host/api/player/segments/1/{rel}.ts?") + assert "sd=6.000000" in lines[8] + + assert lines[9] == "#EXT-X-ENDLIST" + + +@pytest.mark.asyncio +async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + base = tmp_path / "dl" + base.mkdir() + media = base / "v.mp4" + media.write_text("x") + + async def fake_ffprobe(_file: Path): + return SimpleNamespace( + metadata={"duration": "13"}, + streams=lambda: [ + _Stream(v=True, a=False, codec="hevc"), + _Stream(v=False, a=True, codec="flac"), + ], + ) + + monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe) + + m3 = M3u8(download_path=base, url="https://s/") + out = await m3.make_stream(media) + lines = out.splitlines() + + # 3 segments, last has 1.000000 duration and includes vc/ac + sd + rel = quote(str(Path("v.mp4"))) + # First + assert lines[5] == "#EXTINF:6.000000," + assert lines[6].startswith(f"https://s/api/player/segments/0/{rel}.ts?") + assert "vc=1" in lines[6] + assert "ac=1" in lines[6] + # Second + assert lines[7] == "#EXTINF:6.000000," + assert lines[8].startswith(f"https://s/api/player/segments/1/{rel}.ts?") + assert "vc=1" in lines[8] + assert "ac=1" in lines[8] + # Third + assert lines[9] == "#EXTINF:1.000000," + assert lines[10].startswith(f"https://s/api/player/segments/2/{rel}.ts?") + assert "vc=1" in lines[10] + assert "ac=1" in lines[10] + assert "sd=1.000000" in lines[10] + assert lines[11] == "#EXT-X-ENDLIST" + + +@pytest.mark.asyncio +async def test_make_stream_raises_without_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + base = tmp_path / "dl" + base.mkdir() + media = base / "v.mp4" + media.write_text("x") + + async def fake_ffprobe(_file: Path): + return SimpleNamespace(metadata={}) + + monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe) + + m3 = M3u8(download_path=base, url="http://s/") + with pytest.raises(StreamingError, match="Unable to get"): + await m3.make_stream(media) + + +@pytest.mark.asyncio +async def test_make_subtitle(tmp_path: Path) -> None: + base = tmp_path / "dl" + base.mkdir() + sub = base / "dir" / "cap.srt" + sub.parent.mkdir() + sub.write_text("x") + + m3 = M3u8(download_path=base, url="http://h/") + out = await m3.make_subtitle(sub, duration=42.5) + lines = out.splitlines() + + assert lines[0] == "#EXTM3U" + assert lines[1] == "#EXT-X-VERSION:3" + assert lines[2] == "#EXT-X-TARGETDURATION:6" + assert lines[3] == "#EXT-X-MEDIA-SEQUENCE:0" + assert lines[4] == "#EXT-X-PLAYLIST-TYPE:VOD" + assert lines[5] == "#EXTINF:42.5," + rel = quote(str(Path("dir/cap.srt"))) + assert lines[6] == f"http://h/api/player/subtitle/{rel}.vtt" + assert lines[7] == "#EXT-X-ENDLIST" diff --git a/app/tests/test_package_installer.py b/app/tests/test_package_installer.py new file mode 100644 index 00000000..ecff1cb4 --- /dev/null +++ b/app/tests/test_package_installer.py @@ -0,0 +1,270 @@ +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from app.library.PackageInstaller import PackageInstaller, Packages, parse_version + + +class TestParseVersion: + def test_parse_version_basic(self) -> None: + assert parse_version("1.2.3") == (1, 2, 3) + assert parse_version("01.002.0003") == (1, 2, 3) + + def test_parse_version_with_chars(self) -> None: + # Non-digits are stripped per part + assert parse_version("1a.2b.3c") == (1, 2, 3) + assert parse_version("2025.07.21") == (2025, 7, 21) + + +class TestPackages: + def test_packages_from_env_and_file(self, tmp_path: Path) -> None: + req = tmp_path / "req.txt" + req.write_text("\nfoo\nbar==1.0.0\nfoo\n\n") + + pkgs = Packages(env="baz qux", file=str(req), upgrade=True) + + # Order not guaranteed (set), but content should be unique + assert set(pkgs.packages) == {"foo", "bar==1.0.0", "baz", "qux"} + assert pkgs.has_packages() is True + assert pkgs.allow_upgrade() is True + + def test_packages_empty(self) -> None: + pkgs = Packages(env=None, file=None, upgrade=False) + assert pkgs.has_packages() is False + assert pkgs.allow_upgrade() is False + + +class TestPackageInstallerInit: + def test_init_with_explicit_path_adds_to_sys_path(self, tmp_path: Path) -> None: + p = tmp_path / "site" + p.mkdir() + + # Snapshot sys.path length to verify insertion + original_len = len(sys.path) + installer = PackageInstaller(pkg_path=p) + + assert installer.user_site == p + assert sys.path[0] == str(p) + assert len(sys.path) == original_len + 1 + + def test_init_with_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("YTP_CONFIG_PATH", str(tmp_path)) + installer = PackageInstaller(pkg_path=None) + + assert installer.user_site is not None + assert installer.user_site.exists() is True + assert str(installer.user_site) in sys.path + + def test_init_without_path_or_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("YTP_CONFIG_PATH", raising=False) + installer = PackageInstaller(pkg_path=None) + # No user_site is set when no path or env provided + assert installer.user_site is None + + +class TestVersionCompare: + def test_compare_versions_equal(self, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + assert inst.compare_versions("1.2.3", "1.2.3") is True + + def test_compare_versions_yt_dlp_like_padding(self, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + assert inst.compare_versions("2025.7.21", "2025.07.21") is True + assert inst.compare_versions("2025.07.1", "2025.7.01") is True + + def test_compare_versions_not_equal(self, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + assert inst.compare_versions("1.2.3", "1.2.4") is False + + +class TestInstalledAndLatest: + @patch("app.library.PackageInstaller.importlib.metadata.version") + def test_get_installed_version(self, mock_version, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + mock_version.return_value = "1.0.0" + assert inst._get_installed_version("foo") == "1.0.0" + + @patch("app.library.PackageInstaller.importlib.metadata.version") + def test_get_installed_version_not_found(self, mock_version, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + from importlib.metadata import PackageNotFoundError + + mock_version.side_effect = PackageNotFoundError + assert inst._get_installed_version("bar") is None + + @patch("app.library.PackageInstaller.httpx.Client") + def test_get_latest_version_success(self, mock_client, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + + client = MagicMock() + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {"info": {"version": "9.9.9"}} + client.get.return_value = resp + mock_client.return_value.__enter__.return_value = client + + assert inst._get_latest_version("foo") == "9.9.9" + + @patch("app.library.PackageInstaller.httpx.Client") + def test_get_latest_version_non_200(self, mock_client, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + client = MagicMock() + resp = MagicMock() + resp.status_code = 404 + client.get.return_value = resp + mock_client.return_value.__enter__.return_value = client + assert inst._get_latest_version("foo") is None + + @patch("app.library.PackageInstaller.httpx.Client") + def test_get_latest_version_exception(self, mock_client, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + mock_client.side_effect = RuntimeError("boom") + assert inst._get_latest_version("foo") is None + + +class TestInstallCmd: + @patch("app.library.PackageInstaller.subprocess.run") + def test_install_default_latest(self, mock_run, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + # Simulate successful run + mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"out", stderr=b"err") + + ok = inst._install_pkg("pkg") + + assert ok is True + cmd = mock_run.call_args.kwargs["args"] if "args" in mock_run.call_args.kwargs else mock_run.call_args.args[0] + assert cmd[:5] == [sys.executable, "-m", "pip", "install", "--no-warn-script-location"] + assert "--disable-pip-version-check" in cmd + assert "pkg" in cmd + assert "--target" in cmd + assert str(inst.user_site) in cmd + + @patch("app.library.PackageInstaller.subprocess.run") + def test_install_pinned_version(self, mock_run, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e") + + ok = inst._install_pkg("pkg", version="1.2.3") + assert ok is True + cmd = mock_run.call_args.args[0] + assert "pkg==1.2.3" in cmd + + @patch("app.library.PackageInstaller.subprocess.run") + def test_install_git_url_version(self, mock_run, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e") + + ok = inst._install_pkg("pkg", version="git+https://example/repo.git@abc") + assert ok is True + cmd = mock_run.call_args.args[0] + assert "git+https://example/repo.git@abc" in cmd + + @patch("app.library.PackageInstaller.subprocess.run") + def test_install_yt_dlp_nightly(self, mock_run, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e") + + ok = inst._install_pkg("yt_dlp", version="nightly") + assert ok is True + cmd = mock_run.call_args.args[0] + # should include pre-release flag and yt-dlp extra + assert "--pre" in cmd + assert "yt-dlp[default]" in cmd + + @patch("app.library.PackageInstaller.subprocess.run") + def test_install_yt_dlp_master(self, mock_run, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + mock_run.return_value = SimpleNamespace(returncode=0, stdout=b"o", stderr=b"e") + + ok = inst._install_pkg("yt_dlp", version="master") + assert ok is True + cmd = mock_run.call_args.args[0] + assert "git+https://github.com/yt-dlp/yt-dlp.git@master" in cmd + + @patch("app.library.PackageInstaller.subprocess.run") + def test_install_called_process_error(self, mock_run, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + + err = SimpleNamespace(returncode=1, stdout=b"o", stderr=b"e") + + class _CPEError(Exception): + def __init__(self) -> None: + self.returncode = err.returncode + self.stdout = err.stdout + self.stderr = err.stderr + super().__init__("fail") + + mock_run.side_effect = _CPEError() + + with pytest.raises(_CPEError, match="fail"): + inst._install_pkg("pkg") + + +class TestActionAndCheck: + @patch.object(PackageInstaller, "_install_pkg") + @patch.object(PackageInstaller, "_get_installed_version") + def test_action_skips_when_same_pinned(self, mock_get_installed, mock_install, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + mock_get_installed.return_value = "1.2.3" + # compare_versions normal equality should hold + inst.action("pkg==1.2.3") + mock_install.assert_not_called() + + @patch.object(PackageInstaller, "_install_pkg") + @patch.object(PackageInstaller, "_get_installed_version") + @patch.object(PackageInstaller, "_get_latest_version") + def test_action_upgrade_skip_when_latest(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + mock_get_installed.return_value = "2.0.0" + mock_get_latest.return_value = "2.0.0" + inst.action("pkg", upgrade=True) + mock_install.assert_not_called() + + @patch.object(PackageInstaller, "_install_pkg") + @patch.object(PackageInstaller, "_get_installed_version") + @patch.object(PackageInstaller, "_get_latest_version") + def test_action_upgrade_runs_when_newer_available(self, mock_get_latest, mock_get_installed, mock_install, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + mock_get_installed.return_value = "1.0.0" + mock_get_latest.return_value = "1.1.0" + inst.action("pkg", upgrade=True) + mock_install.assert_called_once_with("pkg", version=None) + + @patch.object(PackageInstaller, "_install_pkg") + @patch.object(PackageInstaller, "_get_installed_version") + def test_action_install_when_not_installed(self, mock_get_installed, mock_install, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + mock_get_installed.return_value = None + inst.action("pkg") + mock_install.assert_called_once_with("pkg", version=None) + + def test_check_with_no_packages_or_no_user_site(self, tmp_path: Path) -> None: + # No packages + inst = PackageInstaller(pkg_path=tmp_path) + pkgs = Packages(env=None, file=None, upgrade=False) + inst.check(pkgs) # Should do nothing + + # No user_site => early return + inst2 = PackageInstaller(pkg_path=None) + pkgs2 = Packages(env="a b", file=None, upgrade=False) + inst2.check(pkgs2) + + @patch.object(PackageInstaller, "action") + def test_check_calls_action_and_handles_errors(self, mock_action, tmp_path: Path) -> None: + inst = PackageInstaller(pkg_path=tmp_path) + pkgs = Packages(env="foo bar", file=None, upgrade=True) + + # First call raises, second succeeds + def side_effect(pkg, upgrade=False): # noqa: ARG001 + if pkg == "foo": + msg = "boom" + raise RuntimeError(msg) + + mock_action.side_effect = side_effect + + # Should not raise + inst.check(pkgs) + assert mock_action.call_count == 2 diff --git a/app/tests/test_playlist.py b/app/tests/test_playlist.py new file mode 100644 index 00000000..43813b64 --- /dev/null +++ b/app/tests/test_playlist.py @@ -0,0 +1,116 @@ +from pathlib import Path +from types import SimpleNamespace +from urllib.parse import quote + +import pytest + +from app.library.Playlist import Playlist +from app.library.Utils import StreamingError + + +@pytest.mark.asyncio +async def test_make_playlist_no_subtitles(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Setup paths + base = tmp_path / "downloads" + base.mkdir() + # Use a relative subpath with spaces to validate quoting behavior + media = base / "My Video.mp4" + media.write_text("x") + + # Mock ffprobe to return duration + async def fake_ffprobe(_file: Path): + return SimpleNamespace(metadata={"duration": "60"}) + + monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe) + # No sidecar subtitles + monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: {"subtitle": []}) + + # Patch module-level quote to be robust for Path objects + from urllib.parse import quote as _std_quote + monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v))) + + pl = Playlist(download_path=base, url="http://localhost/") + out = await pl.make(media) + + lines = out.splitlines() + assert lines[0] == "#EXTM3U" + # No subtitles group when none present + assert lines[1] == "#EXT-X-STREAM-INF:PROGRAM-ID=1" + # Final line should point to video endpoint with quoted relative path + expected_ref = quote(str(Path("My Video.mp4"))) + assert lines[2] == f"http://localhost/api/player/m3u8/video/{expected_ref}.m3u8" + + +@pytest.mark.asyncio +async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + base = tmp_path / "downloads" + base.mkdir() + media = base / "dir" / "file.mp4" + media.parent.mkdir() + media.write_text("x") + + # ffprobe returns numeric duration + async def fake_ffprobe(_file: Path): + return SimpleNamespace(metadata={"duration": "12.5"}) + + monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe) + + # Build two subtitle sidecars with names and langs; ensure quoting/relative name used + sub1 = media.with_suffix(".en.srt") + sub2 = media.with_name("another sub.srt") + sidecar = { + "subtitle": [ + {"lang": "en", "file": sub1, "name": "English"}, + {"lang": "fr", "file": sub2, "name": "French"}, + ] + } + monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: sidecar) + + from urllib.parse import quote as _std_quote + monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v))) + + pl = Playlist(download_path=base, url="https://server/") + out = await pl.make(media) + + lines = out.splitlines() + assert lines[0] == "#EXTM3U" + # Two EXT-X-MEDIA lines for subtitles + assert lines[1].startswith("#EXT-X-MEDIA:TYPE=SUBTITLES,") + assert 'NAME="English"' in lines[1] + assert 'LANGUAGE="en"' in lines[1] + assert "duration=12.5" in lines[1] + # URI uses the file name relative to base, replacing the name with sidecar file name and quoted + expected_uri1 = quote(str(Path("dir").joinpath(sub1.name))) + assert f"/subtitle/{expected_uri1}.m3u8" in lines[1] + + assert lines[2].startswith("#EXT-X-MEDIA:TYPE=SUBTITLES,") + assert 'NAME="French"' in lines[2] + assert 'LANGUAGE="fr"' in lines[2] + expected_uri2 = quote(str(Path("dir").joinpath(sub2.name))) + assert f"/subtitle/{expected_uri2}.m3u8" in lines[2] + + # Stream info with SUBTITLES group + assert lines[3] == '#EXT-X-STREAM-INF:PROGRAM-ID=1,SUBTITLES="subs"' + # Final video URL + expected_ref = quote(str(Path("dir/file.mp4"))) + assert lines[4] == f"https://server/api/player/m3u8/video/{expected_ref}.m3u8" + + +@pytest.mark.asyncio +async def test_make_playlist_raises_without_duration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + base = tmp_path / "downloads" + base.mkdir() + media = base / "file.mp4" + media.write_text("x") + + # ffprobe missing duration + async def fake_ffprobe(_file: Path): + return SimpleNamespace(metadata={}) + + monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: {"subtitle": []}) + + pl = Playlist(download_path=base, url="http://localhost/") + + with pytest.raises(StreamingError, match="Unable to get"): + await pl.make(media) diff --git a/app/tests/test_router.py b/app/tests/test_router.py new file mode 100644 index 00000000..e0f5cbe3 --- /dev/null +++ b/app/tests/test_router.py @@ -0,0 +1,130 @@ +import pytest + +from app.library.router import ROUTES, Route, RouteType, add_route, get_route, get_routes, make_route_name, route + + +@pytest.fixture(autouse=True) +def reset_routes(): + # Ensure ROUTES is clean before each test + ROUTES.clear() + yield + ROUTES.clear() + + +class TestRouteType: + def test_all_returns_values(self) -> None: + assert set(RouteType.all()) == {"http", "socket"} + + +class TestMakeRouteName: + def test_basic_http_path(self) -> None: + assert make_route_name("GET", "/api/test") == "get:api.test" + + def test_trailing_slash_and_root(self) -> None: + # Current behavior converts empty part to 'part' + assert make_route_name("post", "/") == "post:part" + assert make_route_name("post", "") == "post:part" + + def test_invalid_chars_and_numbers(self) -> None: + # invalid chars become underscores, leading digits prefixed with p_ + assert make_route_name("GET", "/a-b/c@d/123/0x-ff") == "get:a_b.c_d.p_123.p_0x_ff" + + +class TestRouteDecorator: + @pytest.mark.asyncio + async def test_registers_http_and_no_slash_alias(self) -> None: + # Define an async handler and decorate it + result_bucket: dict[str, int] = {"called": 0} + + @route("GET", "/api/test/") + async def handler() -> str: + result_bucket["called"] += 1 + return "ok" + + # Two routes should be registered: with slash and _no_slash alias + http_routes = get_routes(RouteType.HTTP) + assert "get:api.test" in http_routes + assert "get:api.test_no_slash" in http_routes + + # Verify stored Route objects + r1: Route = http_routes["get:api.test"] + r2: Route = http_routes["get:api.test_no_slash"] + assert r1.method == "GET" + assert r1.path == "/api/test/" + assert r2.method == "GET" + assert r2.path == "/api/test" + + # The wrapper should call the original function + res = await r1.handler() + assert res == "ok" + assert result_bucket["called"] == 1 + + # Check that metadata is preserved by wraps + assert r1.handler.__name__ == handler.__name__ + + def test_decorator_no_slash_disabled(self) -> None: + @route("GET", "/api/one/", no_slash=True) + async def h1(): + return "one" + + http_routes = get_routes(RouteType.HTTP) + assert "get:api.one" in http_routes + assert "get:api.one_no_slash" not in http_routes + + def test_socket_route_registration(self) -> None: + @route(RouteType.SOCKET, "/ws/conn") + async def ws(): + return "socket" + + socket_routes = get_routes(RouteType.SOCKET) + assert "socket:ws.conn" in socket_routes + # No no_slash alias for socket routes + assert "socket:ws.conn_no_slash" not in socket_routes + + +class TestAddRoute: + def test_add_route_http_with_alias(self) -> None: + async def handler(): + return "ok" + + add_route("POST", "/api/create/", handler) + + http_routes = get_routes(RouteType.HTTP) + assert "post:api.create" in http_routes + assert "post:api.create_no_slash" in http_routes + + r = get_route(RouteType.HTTP, "post:api.create") + assert isinstance(r, Route) + assert r.method == "POST" + assert r.path == "/api/create/" + + def test_add_route_socket_without_alias(self) -> None: + async def s(): + return "s" + + add_route(RouteType.SOCKET, "/sock/path/", s) + + socket_routes = get_routes(RouteType.SOCKET) + assert "socket:sock.path" in socket_routes + assert "socket:sock.path_no_slash" not in socket_routes + + def test_add_route_custom_name(self) -> None: + async def h(): + return "x" + + add_route("GET", "/v1/x", h, name="get:v1.custom") + assert get_route(RouteType.HTTP, "get:v1.custom") is not None + + +class TestGetters: + def test_get_routes_returns_copy_like_mapping(self) -> None: + async def h(): + return "x" + + add_route("GET", "/x", h) + routes = get_routes(RouteType.HTTP) + assert isinstance(routes, dict) + assert "get:x" in routes + + def test_get_route_not_found(self) -> None: + assert get_route(RouteType.HTTP, "nonexistent") is None diff --git a/app/tests/test_scheduler.py b/app/tests/test_scheduler.py new file mode 100644 index 00000000..39faed14 --- /dev/null +++ b/app/tests/test_scheduler.py @@ -0,0 +1,289 @@ +import asyncio +import sys +import types +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +# Ensure aiocron module exists to allow importing Scheduler without external dep +if "aiocron" not in sys.modules: + aiocron_stub = types.ModuleType("aiocron") + + class _CronImportStub: # Minimal placeholder; tests will patch real behavior per-test + def __init__(self, *args, **kwargs): + pass + + def stop(self) -> None: + pass + + @property + def uuid(self) -> str: + return "stub-uuid" + + aiocron_stub.Cron = _CronImportStub + sys.modules["aiocron"] = aiocron_stub + + +from app.library.Events import EventBus, Events +from app.library.Scheduler import Scheduler + + +class DummyCron: + """Simple Cron stub to capture construction and allow stop().""" + + def __init__( + self, + *, + spec: str, + func: callable, + args: tuple = (), + kwargs: dict | None = None, + uuid: str | None = None, + start: bool | None = None, + loop: asyncio.AbstractEventLoop | None = None, + ) -> None: + self.spec = spec + self.func = func + self.args = args + self.kwargs = kwargs or {} + self._uuid = uuid or "generated-uuid" + self.start = start + self.loop = loop + self.stopped = False + + @property + def uuid(self) -> str: + return self._uuid + + def stop(self) -> None: + self.stopped = True + + +class FailingCron(DummyCron): + def stop(self) -> None: + msg = "stop failed" + raise RuntimeError(msg) + + +class TestScheduler: + """Tests for the Scheduler singleton and behavior.""" + + def setup_method(self) -> None: + # Reset singletons between tests + Scheduler._reset_singleton() + EventBus._reset_singleton() + + def teardown_method(self) -> None: + Scheduler._reset_singleton() + EventBus._reset_singleton() + + def test_singleton_behavior(self) -> None: + s1 = Scheduler() + s2 = Scheduler() + s3 = Scheduler.get_instance() + + assert s1 is s2 is s3 + + @patch("app.library.Scheduler.Cron", new=DummyCron) + def test_add_creates_and_stores_job(self) -> None: + loop = asyncio.new_event_loop() + try: + sched = Scheduler(loop=loop) + + def fn(a: int, b: int) -> int: # noqa: ARG001 + return 42 + + job_id = sched.add( + timer="*/5 * * * *", + func=fn, + args=(1, 2), + kwargs={"x": 3}, + id="job1", + ) + + assert job_id == "job1" + assert sched.has("job1") is True + + job = sched.get("job1") + assert isinstance(job, DummyCron) + assert job.spec == "*/5 * * * *" + assert job.args == (1, 2) + assert job.kwargs == {"x": 3} + assert job.loop is loop + assert job.start is True + finally: + loop.close() + + @patch("app.library.Scheduler.Cron", new=DummyCron) + def test_add_replaces_existing_job(self) -> None: + sched = Scheduler() + + # Seed with an existing job + old = DummyCron(spec="* * * * *", func=lambda: None, uuid="job1", start=True, loop=sched._loop) + sched._jobs["job1"] = old + + # Replace with a new one + new_id = sched.add(timer="*/2 * * * *", func=lambda: None, id="job1") + + assert new_id == "job1" + assert sched.has("job1") is True + new_job = sched.get("job1") + assert new_job is not old + # Old job should have been stopped via remove() + assert old.stopped is True + + @patch("app.library.Scheduler.Cron", new=DummyCron) + def test_remove_single_job_success(self) -> None: + sched = Scheduler() + cron = DummyCron(spec="* * * * *", func=lambda: None, uuid="jobA", start=True, loop=sched._loop) + sched._jobs["jobA"] = cron + + result = sched.remove("jobA") + + assert result is True + assert cron.stopped is True + assert sched.has("jobA") is False + + @patch("app.library.Scheduler.Cron", new=FailingCron) + def test_remove_single_job_failure_on_stop(self) -> None: + sched = Scheduler() + cron = FailingCron(spec="* * * * *", func=lambda: None, uuid="jobB", start=True, loop=sched._loop) + sched._jobs["jobB"] = cron + + result = sched.remove("jobB") + + assert result is False + # Job should remain since stop failed + assert sched.has("jobB") is True + + @patch("app.library.Scheduler.Cron", new=DummyCron) + def test_remove_list_of_jobs(self) -> None: + sched = Scheduler() + for jid in ("j1", "j2", "j3"): + sched._jobs[jid] = DummyCron(spec="* * * * *", func=lambda: None, uuid=jid, start=True, loop=sched._loop) + + result = sched.remove(["j1", "j2", "j3"]) + + assert result is True + assert all(not sched.has(j) for j in ("j1", "j2", "j3")) + + @pytest.mark.asyncio + @patch("app.library.Scheduler.Cron", new=DummyCron) + async def test_on_shutdown_stops_and_clears_jobs(self) -> None: + from aiohttp import web + + sched = Scheduler() + a = DummyCron(spec="* * * * *", func=lambda: None, uuid="a", start=True, loop=sched._loop) + b = DummyCron(spec="* * * * *", func=lambda: None, uuid="b", start=True, loop=sched._loop) + sched._jobs = {"a": a, "b": b} + + await sched.on_shutdown(web.Application()) + + assert a.stopped is True + assert b.stopped is True + assert len(sched.get_all()) == 0 + + @pytest.mark.asyncio + @patch("app.library.Scheduler.Cron", new=DummyCron) + async def test_attach_registers_shutdown_and_handles_schedule_add_event(self) -> None: + from aiohttp import web + + app = web.Application() + sched = Scheduler() + sched.attach(app) + + # on_shutdown handler should be registered + assert Scheduler.on_shutdown in [cb.__func__ if hasattr(cb, "__func__") else cb for cb in app.on_shutdown] + + # Patch add to verify it is called from event handler + add_spy = MagicMock(wraps=sched.add) + # Bind spy to the instance + sched.add = add_spy + + # Emit schedule add event + EventBus.get_instance().emit( + Events.SCHEDULE_ADD, + data={ + "timer": "*/3 * * * *", + "func": lambda: None, + "args": (1,), + "kwargs": {"k": "v"}, + "id": "evt-job", + }, + ) + + # Allow event loop to schedule and run handler + await asyncio.sleep(0.02) + + add_spy.assert_called_once() + kwargs = add_spy.call_args.kwargs + assert kwargs["timer"] == "*/3 * * * *" + assert callable(kwargs["func"]) is True + assert kwargs["args"] == (1,) + assert kwargs["kwargs"] == {"k": "v"} + assert kwargs["id"] == "evt-job" + + @patch("app.library.Scheduler.Cron") + def test_add_executes_function_when_cron_runs(self, cron_patch) -> None: + # Cron stub that auto-runs the function on creation when start=True + class AutoRunCron(DummyCron): + def __init__(self, *_, spec: str, func: callable, args: tuple = (), kwargs: dict | None = None, uuid: str | None = None, start: bool | None = None, loop: asyncio.AbstractEventLoop | None = None): + super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop) + if start: + # Simulate immediate execution + self.func(*self.args, **self.kwargs) + + cron_patch.side_effect = AutoRunCron + + sched = Scheduler() + ran: dict[str, Any] = {"count": 0, "last": None} + + def job_func(x: int, y: int, label: str = "") -> None: + ran["count"] += 1 + ran["last"] = (x, y, label) + + _ = sched.add(timer="*/1 * * * *", func=job_func, args=(2, 3), kwargs={"label": "ok"}, id="run1") + + assert ran["count"] == 1 + assert ran["last"] == (2, 3, "ok") + + @pytest.mark.asyncio + @patch("app.library.Scheduler.Cron") + async def test_event_schedule_runs_function(self, cron_patch) -> None: + # Cron stub that auto-runs the function on creation + class AutoRunCron(DummyCron): + def __init__(self, *_, spec: str, func: callable, args: tuple = (), kwargs: dict | None = None, uuid: str | None = None, start: bool | None = None, loop: asyncio.AbstractEventLoop | None = None): + super().__init__(spec=spec, func=func, args=args, kwargs=kwargs, uuid=uuid, start=start, loop=loop) + if start: + self.func(*self.args, **self.kwargs) + + cron_patch.side_effect = AutoRunCron + + from aiohttp import web + + app = web.Application() + sched = Scheduler() + sched.attach(app) + + bucket: list[tuple[int, str]] = [] + + def job(val: int, tag: str) -> None: + bucket.append((val, tag)) + + # Emit event that should cause add() and immediate execution via AutoRunCron + EventBus.get_instance().emit( + Events.SCHEDULE_ADD, + data={ + "timer": "*/1 * * * *", + "func": job, + "args": (7,), + "kwargs": {"tag": "evt"}, + "id": "evt-run", + }, + ) + + # Give the event handler a tick to run + await asyncio.sleep(0.02) + + assert bucket == [(7, "evt")] diff --git a/app/tests/test_segments.py b/app/tests/test_segments.py new file mode 100644 index 00000000..5b809aeb --- /dev/null +++ b/app/tests/test_segments.py @@ -0,0 +1,200 @@ +import asyncio +import hashlib +import tempfile +from pathlib import Path +from typing import Any + +import pytest + +from app.library.Segments import Segments + + +class DummyFF: + def __init__(self, v: bool, a: bool) -> None: + self._v = v + self._a = a + + def has_video(self) -> bool: + return self._v + + def has_audio(self) -> bool: + return self._a + + +@pytest.mark.asyncio +async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Create a dummy media file + media = tmp_path / "file.mp4" + media.write_bytes(b"data") + + # Patch ffprobe to report both video and audio + async def fake_ffprobe(_file: Path): + return DummyFF(v=True, a=True) + + monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + + seg = Segments(download_path=str(tmp_path), index=2, duration=5.5, vconvert=False, aconvert=False) + + args = await seg.build_ffmpeg_args(media) + + # Compute expected symlink path used by Segments + tmpFile = Path(tempfile.gettempdir()).joinpath( + f"ytptube_stream.{hashlib.sha256(str(media).encode()).hexdigest()}" + ) + + # Start time is duration * index with 6 decimals for non-zero index + assert "-ss" in args + assert args[args.index("-ss") + 1] == f"{5.5 * 2:.6f}" + # Duration formatting + assert "-t" in args + assert args[args.index("-t") + 1] == f"{5.5:.6f}" + # Input uses file: + assert "-i" in args + assert args[args.index("-i") + 1] == f"file:{tmpFile}" + # Includes video and audio mapping and codecs, forced convert True in __init__ + assert "-map" in args + assert "0:v:0" in args + assert "-codec:v" in args + assert seg.vcodec in args + assert "-codec:a" in args + assert seg.acodec in args + # Output format: ensure -f mpegts and pipe:1 present + assert args[-1] == "pipe:1" + assert "-f" in args + assert args[args.index("-f") + 1] == "mpegts" + + +@pytest.mark.asyncio +async def test_build_ffmpeg_args_audio_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + media = tmp_path / "file.mp3" + media.write_bytes(b"data") + + async def fake_ffprobe(_file: Path): + return DummyFF(v=False, a=True) + + monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + + seg = Segments(download_path=str(tmp_path), index=0, duration=9.0, vconvert=False, aconvert=False) + args = await seg.build_ffmpeg_args(media) + + # Start at 0 for index 0 + assert "-ss" in args + assert args[args.index("-ss") + 1] == f"{0:.6f}" + # Should not include video mapping + assert "0:v:0" not in args + # Should include audio mapping and codec + assert "-map" in args + assert "0:a:0" in args + assert "-codec:a" in args + assert seg.acodec in args + + +class _FakeStdout: + def __init__(self, chunks: list[bytes]) -> None: + self._chunks = chunks + + async def read(self, _size: int) -> bytes: + if not self._chunks: + return b"" + return self._chunks.pop(0) + + +class _FakeProc: + def __init__(self, chunks: list[bytes]) -> None: + self.stdout = _FakeStdout(chunks) + self.stderr = _FakeStdout([]) + self.terminated = False + self.killed = False + + async def wait(self) -> int: + return 0 + + def terminate(self) -> None: + self.terminated = True + + def kill(self) -> None: + self.killed = True + + +class _FakeResp: + def __init__(self, fail_with: Exception | None = None) -> None: + self.data: bytearray = bytearray() + self.eof = False + self._exc = fail_with + + async def write(self, data: bytes) -> None: + if self._exc: + raise self._exc + self.data.extend(data) + + async def write_eof(self) -> None: + self.eof = True + + +@pytest.mark.asyncio +async def test_stream_normal_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + async def fake_ffprobe(_file: Path): + return DummyFF(v=True, a=True) + + monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + + # Process that yields two chunks and then EOF + proc = _FakeProc([b"abc", b"def", b""]) + + async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any): + return proc + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec) + + seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True) + resp = _FakeResp() + await seg.stream(tmp_path / "file.mp4", resp) + + assert bytes(resp.data) == b"abcdef" + assert resp.eof is True + + +@pytest.mark.asyncio +async def test_stream_client_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + async def fake_ffprobe(_file: Path): + return DummyFF(v=True, a=True) + + monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + + proc = _FakeProc([b"abc", b"def"]) # will attempt to write and fail + + async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any): + return proc + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec) + + seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True) + resp = _FakeResp(fail_with=ConnectionResetError()) + + await seg.stream(tmp_path / "file.mp4", resp) + + # Should not write EOF due to client disconnect + assert resp.eof is False + + +@pytest.mark.asyncio +async def test_stream_cancelled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + async def fake_ffprobe(_file: Path): + return DummyFF(v=True, a=True) + + monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + + proc = _FakeProc([b"abc"]) # only one chunk + + async def fake_create_subprocess_exec(*_args: Any, **_kwargs: Any): + return proc + + monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec) + + seg = Segments(download_path=str(tmp_path), index=0, duration=1.0, vconvert=True, aconvert=True) + # Fail with CancelledError on write to hit inner disconnection branch + resp = _FakeResp(fail_with=asyncio.CancelledError()) + await seg.stream(tmp_path / "file.mp4", resp) + # Inner branch treats it as client disconnected; no EOF and no termination + assert resp.eof is False + assert proc.terminated is False diff --git a/app/tests/test_subtitle.py b/app/tests/test_subtitle.py new file mode 100644 index 00000000..fa1f0d52 --- /dev/null +++ b/app/tests/test_subtitle.py @@ -0,0 +1,115 @@ +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from app.library.Subtitle import Subtitle, ms_to_timestamp + + +class TestMsToTimestamp: + def test_ms_to_timestamp_basic(self) -> None: + assert ms_to_timestamp(0) == "0:00:00.00" + assert ms_to_timestamp(9) == "0:00:00.00" + assert ms_to_timestamp(10) == "0:00:00.01" + assert ms_to_timestamp(12345) == "0:00:12.34" + # 1 hour, 2 minutes, 3 seconds + assert ms_to_timestamp(3600000 + 120000 + 3000) == "1:02:03.00" + # Over 10 hours (SubStation limit is < 10h, our override must exceed) + assert ms_to_timestamp(12 * 3600000 + 34 * 60000 + 56 * 1000 + 780) == "12:34:56.78" + # Well over 36 hours to ensure no clamping at 9:59:59.99 + assert ms_to_timestamp(37 * 3600000 + 12 * 60000 + 34 * 1000 + 560) == "37:12:34.56" + + +@pytest.mark.asyncio +async def test_make_unsupported_extension(tmp_path: Path) -> None: + srt = tmp_path / "sub.txt" + srt.write_text("not a subtitle") + + sub = Subtitle() + with pytest.raises(Exception, match="subtitle type is not supported"): + await sub.make(srt) + + +@pytest.mark.asyncio +async def test_make_vtt_reads_file(tmp_path: Path) -> None: + vtt = tmp_path / "file.vtt" + content = "WEBVTT\n\n00:00:00.00 --> 00:00:01.00\nHello" + vtt.write_text(content) + + sub = Subtitle() + out = await sub.make(vtt) + assert out == content + + +class _DummySubs: + def __init__(self, events): + self.events = events + self.snapshot = None + + def to_string(self, fmt: str) -> str: # noqa: ARG002 + # Record end times to verify pop behavior + self.snapshot = [e.end for e in self.events] + return "OUT" + + +@pytest.mark.asyncio +async def test_make_no_events_raises(tmp_path: Path) -> None: + srt = tmp_path / "sub.srt" + srt.write_text("dummy") + + with patch("app.library.Subtitle.pysubs2.load") as mock_load: + mock_load.return_value = _DummySubs(events=[]) + sub = Subtitle() + with pytest.raises(Exception, match="No subtitle events were found"): + await sub.make(srt) + + +@pytest.mark.asyncio +async def test_make_single_event_returns_vtt(tmp_path: Path) -> None: + srt = tmp_path / "sub.ass" + srt.write_text("dummy") + + single = SimpleNamespace(end=1000) + d = _DummySubs(events=[single]) + + with patch("app.library.Subtitle.pysubs2.load", return_value=d): + sub = Subtitle() + out = await sub.make(srt) + assert out == "OUT" + # Snapshot should contain the single event + assert d.snapshot == [1000] + + +@pytest.mark.asyncio +async def test_make_two_events_pop_first_when_ends_equal(tmp_path: Path) -> None: + srt = tmp_path / "sub.srt" + srt.write_text("dummy") + + e1 = SimpleNamespace(end=5000) + e2 = SimpleNamespace(end=5000) + d = _DummySubs(events=[e1, e2]) + + with patch("app.library.Subtitle.pysubs2.load", return_value=d): + sub = Subtitle() + out = await sub.make(srt) + assert out == "OUT" + # Since ends are equal, first should be popped => only last remains + assert d.snapshot == [5000] + + +@pytest.mark.asyncio +async def test_make_two_events_no_pop_when_different(tmp_path: Path) -> None: + srt = tmp_path / "sub.srt" + srt.write_text("dummy") + + e1 = SimpleNamespace(end=5000) + e2 = SimpleNamespace(end=6000) + d = _DummySubs(events=[e1, e2]) + + with patch("app.library.Subtitle.pysubs2.load", return_value=d): + sub = Subtitle() + out = await sub.make(srt) + assert out == "OUT" + # Both remain since ends differ + assert d.snapshot == [5000, 6000] diff --git a/app/tests/test_ytdlp_module.py b/app/tests/test_ytdlp_module.py new file mode 100644 index 00000000..bb38bfe7 --- /dev/null +++ b/app/tests/test_ytdlp_module.py @@ -0,0 +1,81 @@ + +from unittest.mock import MagicMock, patch + +from app.library.Utils import REMOVE_KEYS +from app.library.ytdlp import _ArchiveProxy, ytdlp_options + + +class TestArchiveProxy: + def test_bool_and_falsey_cases(self) -> None: + # No file path means proxy is falsey and operations return False + p = _ArchiveProxy(file=None) + assert bool(p) is False + assert ("id" in p) is False + assert p.add("id") is False + + # Empty item also returns False + p2 = _ArchiveProxy(file="/tmp/archive.txt") + assert bool(p2) is True + assert ("" in p2) is False + assert p2.add("") is False + + @patch("app.library.Archiver.Archiver.get_instance") + def test_contains_and_add_delegate_to_archiver(self, mock_get_instance) -> None: + arch = MagicMock() + mock_get_instance.return_value = arch + + p = _ArchiveProxy(file="/tmp/archive.txt") + + # contains -> read(file, [item]) and check membership + arch.read.return_value = ["abc"] + assert ("abc" in p) is True + arch.read.assert_called_with("/tmp/archive.txt", ["abc"]) + + arch.read.return_value = [] + assert ("xyz" in p) is False + + # add -> add(file, [item]) returns boolean + arch.add.return_value = True + assert p.add("abc") is True + arch.add.assert_called_with("/tmp/archive.txt", ["abc"]) + + arch.add.return_value = False + assert p.add("xyz") is False + + +class TestYtDlpOptions: + def test_options_structure_and_no_suppresshelp(self) -> None: + opts = ytdlp_options() + + assert isinstance(opts, list) + assert len(opts) > 0 + + # Every entry should have required keys + for o in opts: + assert {"flags", "description", "group", "ignored"} <= set(o.keys()) + assert isinstance(o["flags"], list) + assert len(o["flags"]) > 0 + # Ensure SUPPRESSHELP has been normalized away + if isinstance(o.get("description"), str): + assert "SUPPRESSHELP" not in o["description"] + + def test_ignored_flags_match_remove_keys(self) -> None: + # Collect the flags that should be ignored from REMOVE_KEYS + ignored_flags: set[str] = { + f.strip() for group in REMOVE_KEYS for v in group.values() for f in v.split(",") if f.strip() + } + + opts = ytdlp_options() + + # Map flag -> ignored value as reported by our function (first match wins) + flag_to_ignored: dict[str, bool] = {} + for o in opts: + for f in o["flags"]: + if f not in flag_to_ignored: + flag_to_ignored[f] = bool(o["ignored"]) # normalize to bool + + # For any ignored flag that actually exists in yt-dlp parser, ensure it is marked ignored + present_ignored_flags = [f for f in ignored_flags if f in flag_to_ignored] + # We expect at least one to be present (e.g., -P / --paths, etc.) + assert len(present_ignored_flags) > 0 + assert all(flag_to_ignored[f] is True for f in present_ignored_flags)