Files
Tom FluxandClaude Opus 5 18bb2e420b Initial implementation of youtube-automate
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>
2026-08-11 21:42:48 +01:00

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