extends tests

This commit is contained in:
arabcoders 2025-09-13 16:50:34 +03:00
parent 0b86df87c0
commit c91f199939
15 changed files with 2237 additions and 3 deletions

View file

@ -22,6 +22,7 @@
"asyncio",
"attl",
"autonumber",
"autouse",
"bgutil",
"bgutilhttp",
"bilibili",
@ -42,6 +43,7 @@
"dateparser",
"daterange",
"defusedxml",
"delenv",
"dlfields",
"dotdot",
"dotenv",
@ -56,6 +58,7 @@
"euuo",
"eventbus",
"excepthook",
"falsey",
"faststart",
"Fetc",
"finaldir",

View file

@ -8,13 +8,13 @@ from pysubs2.time import ms_to_times
from .Utils import ALLOWED_SUBS_EXTENSIONS
LOG = logging.getLogger("player.subtitle")
LOG: logging.Logger = logging.getLogger("player.subtitle")
def ms_to_timestamp(ms: int) -> str:
ms = max(0, ms)
h, m, s, ms = ms_to_times(ms)
cs = ms // 10
cs: int = ms // 10
return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}"
@ -24,7 +24,7 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp
class Subtitle:
async def make(self, file: Path) -> str:
if file.suffix not in ALLOWED_SUBS_EXTENSIONS:
msg = f"File '{file}' subtitle type is not supported."
msg: str = f"File '{file}' subtitle type is not supported."
raise Exception(msg)
if file.suffix == ".vtt":

View 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
View 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
View 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
View 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()

View 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
View 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"

View 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
View 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
View 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
View 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
View 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
View 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]

View 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)