extends tests
This commit is contained in:
parent
0b86df87c0
commit
c91f199939
15 changed files with 2237 additions and 3 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
86
app/tests/test_backgroundworker.py
Normal file
86
app/tests/test_backgroundworker.py
Normal file
|
|
@ -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))
|
||||
157
app/tests/test_datastore.py
Normal file
157
app/tests/test_datastore.py
Normal file
|
|
@ -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
|
||||
376
app/tests/test_download.py
Normal file
376
app/tests/test_download.py
Normal file
|
|
@ -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)
|
||||
167
app/tests/test_itemdto.py
Normal file
167
app/tests/test_itemdto.py
Normal file
|
|
@ -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()
|
||||
98
app/tests/test_logwrapper.py
Normal file
98
app/tests/test_logwrapper.py
Normal file
|
|
@ -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)
|
||||
146
app/tests/test_m3u8.py
Normal file
146
app/tests/test_m3u8.py
Normal file
|
|
@ -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"
|
||||
270
app/tests/test_package_installer.py
Normal file
270
app/tests/test_package_installer.py
Normal file
|
|
@ -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
|
||||
116
app/tests/test_playlist.py
Normal file
116
app/tests/test_playlist.py
Normal file
|
|
@ -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)
|
||||
130
app/tests/test_router.py
Normal file
130
app/tests/test_router.py
Normal file
|
|
@ -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
|
||||
289
app/tests/test_scheduler.py
Normal file
289
app/tests/test_scheduler.py
Normal file
|
|
@ -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")]
|
||||
200
app/tests/test_segments.py
Normal file
200
app/tests/test_segments.py
Normal file
|
|
@ -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:<symlink>
|
||||
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
|
||||
115
app/tests/test_subtitle.py
Normal file
115
app/tests/test_subtitle.py
Normal file
|
|
@ -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]
|
||||
81
app/tests/test_ytdlp_module.py
Normal file
81
app/tests/test_ytdlp_module.py
Normal file
|
|
@ -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)
|
||||
Loading…
Reference in a new issue