A DVR for YouTube subscriptions, laid out so Jellyfin presents each channel as a show and each video as an episode. Cron-driven, idempotent, with a public admin UI for a non-operator. Verified end to end on susan against three real channels: PO tokens, h264 downloads, Jellyfin resolution from local NFOs with all providers disabled, retention and tombstones. Corrections to the original design handover (specs.md documents each with the evidence, and specs.handover-original.md preserves the original): - The format sort selected 360p. Ranking acodec above res makes `bv*` prefer the combined 360p stream, which carries AAC, over the 720p video-only stream whose acodec is none. vcodec now leads, so a video without h264 at 720p yields h264 lower down rather than VP9 this hardware cannot transcode. - yt-dlp now requires a JS runtime and the yt-dlp-ejs solver scripts, which only ship with the [default] extra. Without them the n challenge fails and the mweb formats disappear entirely. - --flat-playlist carries no upload dates, so the specced client-side date filter for backfill was impossible. Backfill is RSS-first. - skipped_old was terminal, so raising a channel's retention appeared to do nothing. Added an explicit rescan. - is_upcoming premieres now defer and retry instead of being skipped forever. - TubeArchivist is gone, so the media root and the tube.jihakuz.xyz vhost were both reclaimed; the latter still pointed at its dead port. 240 offline tests, no network and no real yt-dlp invocation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
88 lines
2.6 KiB
Python
88 lines
2.6 KiB
Python
"""Test fixtures.
|
|
|
|
Every path the application uses is redirected into a tmpdir. No test touches
|
|
the network, the real media tree, or a real yt-dlp.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
# config resolves its paths at import time, so the environment has to be set
|
|
# before anything from the package is imported.
|
|
_SANDBOX = Path(tempfile.mkdtemp(prefix="yta-tests-"))
|
|
os.environ.setdefault("YTA_STATE_DIR", str(_SANDBOX / "state"))
|
|
os.environ.setdefault("YTA_MEDIA_ROOT", str(_SANDBOX / "media"))
|
|
os.environ.setdefault("YTA_DB_PATH", str(_SANDBOX / "state" / "subs.db"))
|
|
os.environ.setdefault("YTA_LOCK_PATH", str(_SANDBOX / "state" / "run.lock"))
|
|
os.environ.setdefault("YTA_VENV_BIN", str(_SANDBOX / "venv" / "bin"))
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from youtube_automate import config, db, util, videos # noqa: E402
|
|
from youtube_automate.settings import Settings # noqa: E402
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
|
|
|
@pytest.fixture()
|
|
def media_root(tmp_path, monkeypatch):
|
|
"""Point the media root and work dir at a per-test tmpdir."""
|
|
root = tmp_path / "media"
|
|
work = root / ".work"
|
|
work.mkdir(parents=True)
|
|
(work / ".ignore").touch()
|
|
monkeypatch.setattr(config, "MEDIA_ROOT", root)
|
|
monkeypatch.setattr(config, "WORK_DIR", work)
|
|
return root
|
|
|
|
|
|
@pytest.fixture()
|
|
def conn(tmp_path):
|
|
connection = db.connect(tmp_path / "subs.db")
|
|
yield connection
|
|
connection.close()
|
|
|
|
|
|
@pytest.fixture()
|
|
def settings(conn):
|
|
return Settings(conn)
|
|
|
|
|
|
@pytest.fixture()
|
|
def channel(conn):
|
|
"""One subscribed channel, returned as a row."""
|
|
with conn:
|
|
conn.execute(
|
|
"INSERT INTO channel (channel_id, handle, title, description, dir_name, added_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
(
|
|
"UCW7jUEpYT_t0Gsf632d6_wQ",
|
|
"@clabretro",
|
|
"clabretro",
|
|
"Retro computing",
|
|
"clabretro",
|
|
util.utcnow_iso(),
|
|
),
|
|
)
|
|
return conn.execute("SELECT * FROM channel WHERE dir_name = 'clabretro'").fetchone()
|
|
|
|
|
|
def add_video(conn, channel_pk, video_id, **kwargs):
|
|
"""Insert a video row with sensible defaults."""
|
|
defaults = {
|
|
"title": f"Video {video_id}",
|
|
"upload_date": "2026-08-01",
|
|
"state": videos.PENDING,
|
|
"discovery_source": videos.SOURCE_UULF,
|
|
}
|
|
defaults.update(kwargs)
|
|
videos.insert(conn, channel_pk=channel_pk, video_id=video_id, **defaults)
|
|
return videos.get(conn, video_id)
|
|
|
|
|
|
def feed_bytes(name: str) -> bytes:
|
|
return (FIXTURES / name).read_bytes()
|