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",
|
"asyncio",
|
||||||
"attl",
|
"attl",
|
||||||
"autonumber",
|
"autonumber",
|
||||||
|
"autouse",
|
||||||
"bgutil",
|
"bgutil",
|
||||||
"bgutilhttp",
|
"bgutilhttp",
|
||||||
"bilibili",
|
"bilibili",
|
||||||
|
|
@ -42,6 +43,7 @@
|
||||||
"dateparser",
|
"dateparser",
|
||||||
"daterange",
|
"daterange",
|
||||||
"defusedxml",
|
"defusedxml",
|
||||||
|
"delenv",
|
||||||
"dlfields",
|
"dlfields",
|
||||||
"dotdot",
|
"dotdot",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
|
@ -56,6 +58,7 @@
|
||||||
"euuo",
|
"euuo",
|
||||||
"eventbus",
|
"eventbus",
|
||||||
"excepthook",
|
"excepthook",
|
||||||
|
"falsey",
|
||||||
"faststart",
|
"faststart",
|
||||||
"Fetc",
|
"Fetc",
|
||||||
"finaldir",
|
"finaldir",
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ from pysubs2.time import ms_to_times
|
||||||
|
|
||||||
from .Utils import ALLOWED_SUBS_EXTENSIONS
|
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:
|
def ms_to_timestamp(ms: int) -> str:
|
||||||
ms = max(0, ms)
|
ms = max(0, ms)
|
||||||
h, m, s, ms = ms_to_times(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}"
|
return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp
|
||||||
class Subtitle:
|
class Subtitle:
|
||||||
async def make(self, file: Path) -> str:
|
async def make(self, file: Path) -> str:
|
||||||
if file.suffix not in ALLOWED_SUBS_EXTENSIONS:
|
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)
|
raise Exception(msg)
|
||||||
|
|
||||||
if file.suffix == ".vtt":
|
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