ytptube/app/tests/test_download.py
2025-09-13 18:35:41 +03:00

376 lines
11 KiB
Python

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)