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>
This commit is contained in:
Tom Flux
2026-08-11 21:42:48 +01:00
co-authored by Claude Opus 5
commit 18bb2e420b
44 changed files with 7188 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
"""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()
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015"
xmlns:media="http://search.yahoo.com/mrss/"
xmlns="http://www.w3.org/2005/Atom">
<id>yt:playlist:UULFW7jUEpYT_t0Gsf632d6_wQ</id>
<title>Uploads from clabretro</title>
<entry>
<id>yt:video:08Ajr5fP52I</id>
<yt:videoId>08Ajr5fP52I</yt:videoId>
<yt:channelId>UCW7jUEpYT_t0Gsf632d6_wQ</yt:channelId>
<title>Learning to Design 3D Prints</title>
<published>2026-08-07T15:00:11+00:00</published>
<media:group>
<media:description>Tinkercad &amp; a cheap printer. Part 1/3 &lt;of a series&gt;.</media:description>
</media:group>
</entry>
<entry>
<id>yt:video:8k8nAQq0s_s</id>
<yt:videoId>8k8nAQq0s_s</yt:videoId>
<yt:channelId>UCW7jUEpYT_t0Gsf632d6_wQ</yt:channelId>
<title>Trying to use a Nortel PBX: part two</title>
<published>2026-08-02T14:30:00+00:00</published>
<media:group>
<media:description>Telephony experiments.</media:description>
</media:group>
</entry>
<entry>
<id>yt:video:vcYYcQyecNQ</id>
<yt:videoId>vcYYcQyecNQ</yt:videoId>
<yt:channelId>UCW7jUEpYT_t0Gsf632d6_wQ</yt:channelId>
<title>IBM Director on an xSeries 346 from 2004</title>
<published>2026-06-17T12:00:00+00:00</published>
<media:group>
<media:description>Old enterprise management software.</media:description>
</media:group>
</entry>
</feed>
+146
View File
@@ -0,0 +1,146 @@
"""Password hashing, session cookies, CSRF tokens and login throttling."""
import time
from youtube_automate.web import auth
class TestPasswords:
def test_round_trip(self):
stored = auth.hash_password("correct horse battery staple")
assert auth.verify_password(stored, "correct horse battery staple")
def test_wrong_password_rejected(self):
stored = auth.hash_password("secret")
assert not auth.verify_password(stored, "Secret")
assert not auth.verify_password(stored, "")
def test_salt_makes_hashes_unique(self):
assert auth.hash_password("same") != auth.hash_password("same")
def test_hash_is_not_the_plaintext(self):
assert "secret" not in auth.hash_password("secret")
def test_empty_stored_hash_rejects_everything(self):
assert not auth.verify_password("", "anything")
def test_malformed_stored_hash_does_not_raise(self):
for junk in ("nonsense", "scrypt$bad", "a$b$c$d$e$f", "scrypt$x$y$z$q$r"):
assert auth.verify_password(junk, "anything") is False
def test_unicode_password(self):
stored = auth.hash_password("pässwörd🎬")
assert auth.verify_password(stored, "pässwörd🎬")
class TestSessions:
def test_issue_and_verify(self):
secret = auth.new_secret()
token = auth.issue_session(secret)
assert auth.verify_session(secret, token)
def test_a_different_secret_rejects(self):
token = auth.issue_session(auth.new_secret())
assert not auth.verify_session(auth.new_secret(), token)
def test_tampered_payload_rejected(self):
secret = auth.new_secret()
token = auth.issue_session(secret)
payload, signature = token.split(".", 1)
assert not auth.verify_session(secret, f"{payload}x.{signature}")
def test_tampered_signature_rejected(self):
secret = auth.new_secret()
payload, _ = auth.issue_session(secret).split(".", 1)
assert not auth.verify_session(secret, f"{payload}.deadbeef")
def test_garbage_rejected(self):
secret = auth.new_secret()
for junk in ("", "no-dot", "a.b.c", "...."):
assert auth.verify_session(secret, junk) is False
def test_expires_after_a_year(self):
secret = auth.new_secret()
issued = time.time() - auth.SESSION_MAX_AGE - 10
token = auth.issue_session(secret, issued_at=issued)
assert not auth.verify_session(secret, token)
def test_still_valid_just_inside_a_year(self):
secret = auth.new_secret()
issued = time.time() - auth.SESSION_MAX_AGE + 60
token = auth.issue_session(secret, issued_at=issued)
assert auth.verify_session(secret, token)
def test_a_token_from_the_future_is_rejected(self):
secret = auth.new_secret()
token = auth.issue_session(secret, issued_at=time.time() + 3600)
assert not auth.verify_session(secret, token)
class TestCookie:
def test_carries_the_hardening_flags(self):
header = auth.cookie_header("abc")
for flag in ("HttpOnly", "Secure", "SameSite=Lax", "Path=/"):
assert flag in header
assert f"Max-Age={auth.SESSION_MAX_AGE}" in header
def test_secure_can_be_omitted_for_local_http_testing(self):
assert "Secure" not in auth.cookie_header("abc", secure=False)
def test_clear_cookie_expires_immediately(self):
assert "Max-Age=0" in auth.clear_cookie_header()
class TestCsrf:
def test_token_verifies(self):
secret, session = auth.new_secret(), auth.issue_session(auth.new_secret())
token = auth.csrf_token(secret, session)
assert auth.verify_csrf(secret, session, token)
def test_token_is_bound_to_the_session(self):
secret = auth.new_secret()
one = auth.issue_session(secret, issued_at=1000)
two = auth.issue_session(secret, issued_at=2000)
assert not auth.verify_csrf(secret, two, auth.csrf_token(secret, one))
def test_empty_token_rejected(self):
secret, session = auth.new_secret(), "sess"
assert not auth.verify_csrf(secret, session, "")
def test_wrong_token_rejected(self):
secret, session = auth.new_secret(), "sess"
assert not auth.verify_csrf(secret, session, "deadbeef")
class TestThrottle:
def test_allows_up_to_the_limit(self):
throttle = auth.LoginThrottle(max_failures=3, lockout=60)
for _ in range(2):
throttle.record_failure("1.2.3.4", now=1000)
assert not throttle.locked("1.2.3.4", now=1000)
def test_locks_after_the_limit(self):
throttle = auth.LoginThrottle(max_failures=3, lockout=60)
for _ in range(3):
throttle.record_failure("1.2.3.4", now=1000)
assert throttle.locked("1.2.3.4", now=1000)
def test_lock_expires(self):
throttle = auth.LoginThrottle(max_failures=3, lockout=60)
for _ in range(3):
throttle.record_failure("1.2.3.4", now=1000)
assert not throttle.locked("1.2.3.4", now=1061)
def test_success_clears_the_counter(self):
throttle = auth.LoginThrottle(max_failures=3, lockout=60)
for _ in range(2):
throttle.record_failure("1.2.3.4", now=1000)
throttle.record_success("1.2.3.4")
assert not throttle.locked("1.2.3.4", now=1000)
def test_addresses_are_tracked_separately(self):
throttle = auth.LoginThrottle(max_failures=2, lockout=60)
for _ in range(2):
throttle.record_failure("1.1.1.1", now=1000)
assert throttle.locked("1.1.1.1", now=1000)
assert not throttle.locked("2.2.2.2", now=1000)
+109
View File
@@ -0,0 +1,109 @@
"""Channel URL resolution and the subscribe/unsubscribe lifecycle."""
import pytest
from youtube_automate import channels, config
class TestNormaliseUrl:
@pytest.mark.parametrize(
"raw, expected",
[
("https://www.youtube.com/@clabretro", "https://www.youtube.com/@clabretro"),
("http://youtube.com/c/name", "http://youtube.com/c/name"),
("@clabretro", "https://www.youtube.com/@clabretro"),
("clabretro", "https://www.youtube.com/@clabretro"),
(
"UCW7jUEpYT_t0Gsf632d6_wQ",
"https://www.youtube.com/channel/UCW7jUEpYT_t0Gsf632d6_wQ",
),
("www.youtube.com/@x", "https://www.youtube.com/@x"),
(" @spaced ", "https://www.youtube.com/@spaced"),
],
)
def test_accepted_forms(self, raw, expected):
assert channels.normalise_url(raw) == expected
@pytest.mark.parametrize("raw", ["", " ", "not a channel!!", "@@@"])
def test_rejected_forms(self, raw):
with pytest.raises(channels.ResolutionError):
channels.normalise_url(raw)
def test_channel_id_must_be_the_right_shape(self):
# Too short to be a real UC id, so it is treated as a handle instead.
assert channels.normalise_url("UCshort") == "https://www.youtube.com/@UCshort"
class TestUulfPlaylistId:
def test_swaps_the_uc_prefix_for_uulf(self):
assert (
channels.uulf_playlist_id("UCW7jUEpYT_t0Gsf632d6_wQ")
== "UULFW7jUEpYT_t0Gsf632d6_wQ"
)
def test_length_is_preserved(self):
channel_id = "UCW7jUEpYT_t0Gsf632d6_wQ"
assert len(channels.uulf_playlist_id(channel_id)) == len(channel_id) + 2
class TestThumbnailPicking:
def test_finds_the_requested_id(self):
thumbs = [
{"id": "avatar_uncropped", "url": "http://a/avatar.jpg"},
{"id": "banner_uncropped", "url": "http://a/banner.jpg"},
]
assert channels._pick_thumbnail(thumbs, "banner_uncropped") == "http://a/banner.jpg"
def test_returns_none_when_absent(self):
assert channels._pick_thumbnail([{"id": "other", "url": "u"}], "avatar_uncropped") is None
def test_ignores_entries_without_a_url(self):
assert channels._pick_thumbnail([{"id": "avatar_uncropped"}], "avatar_uncropped") is None
def test_empty_list(self):
assert channels._pick_thumbnail([], "avatar_uncropped") is None
class TestUniqueDirName:
def test_first_use_is_unchanged(self, conn):
assert channels._unique_dir_name(conn, "clabretro") == "clabretro"
def test_collision_gets_a_suffix(self, conn, channel):
assert channels._unique_dir_name(conn, "clabretro") == "clabretro (2)"
def test_repeated_collisions_keep_counting(self, conn, channel):
with conn:
conn.execute(
"INSERT INTO channel (channel_id, title, dir_name, added_at) "
"VALUES ('UCx', 'clabretro', 'clabretro (2)', '2026-01-01')"
)
assert channels._unique_dir_name(conn, "clabretro") == "clabretro (3)"
class TestUnsubscribe:
def test_removes_the_directory_and_the_rows(self, conn, channel, media_root, monkeypatch):
monkeypatch.setattr(config, "MEDIA_ROOT", media_root)
channel_dir = media_root / channel["dir_name"]
(channel_dir / "Season 2026").mkdir(parents=True)
(channel_dir / "tvshow.nfo").write_text("<tvshow/>")
from conftest import add_video
add_video(conn, channel["id"], "v1")
title = channels.unsubscribe(conn, channel["id"])
assert title == "clabretro"
assert not channel_dir.exists()
assert conn.execute("SELECT COUNT(*) FROM channel").fetchone()[0] == 0
# ON DELETE CASCADE must take the videos with it.
assert conn.execute("SELECT COUNT(*) FROM video").fetchone()[0] == 0
def test_missing_channel_raises(self, conn):
with pytest.raises(LookupError):
channels.unsubscribe(conn, 999)
def test_tolerates_a_missing_directory(self, conn, channel, media_root, monkeypatch):
monkeypatch.setattr(config, "MEDIA_ROOT", media_root)
channels.unsubscribe(conn, channel["id"])
assert conn.execute("SELECT COUNT(*) FROM channel").fetchone()[0] == 0
+259
View File
@@ -0,0 +1,259 @@
"""Discovery: feed parsing, the fallback path, and the two repair mechanisms."""
from datetime import date, timedelta
import pytest
from conftest import add_video, feed_bytes
from youtube_automate import discovery, util, videos
class TestParseEntries:
def test_parses_all_entries(self):
entries = discovery.parse_entries(feed_bytes("uulf_feed.xml"))
assert len(entries) == 3
assert entries[0]["video_id"] == "08Ajr5fP52I"
assert entries[0]["published"] == date(2026, 8, 7)
def test_unescapes_description_entities(self):
entries = discovery.parse_entries(feed_bytes("uulf_feed.xml"))
assert "&" in entries[0]["description"]
assert "<of a series>" in entries[0]["description"]
def test_keeps_characters_the_filename_would_strip(self):
entries = discovery.parse_entries(feed_bytes("uulf_feed.xml"))
assert entries[1]["title"] == "Trying to use a Nortel PBX: part two"
def test_empty_feed_yields_nothing(self):
empty = b'<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"/>'
assert discovery.parse_entries(empty) == []
def test_unparseable_feed_raises(self):
with pytest.raises(discovery.FeedUnavailable):
discovery.parse_entries(b"<not xml")
def test_entry_without_video_id_is_skipped(self):
payload = (
b'<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom">'
b"<entry><title>no id</title></entry></feed>"
)
assert discovery.parse_entries(payload) == []
class TestFeedUrls:
def test_uulf_strips_the_uc_prefix(self):
url = discovery.uulf_feed_url("UCW7jUEpYT_t0Gsf632d6_wQ")
assert "playlist_id=UULFW7jUEpYT_t0Gsf632d6_wQ" in url
def test_uc_feed_uses_channel_id(self):
url = discovery.uc_feed_url("UCW7jUEpYT_t0Gsf632d6_wQ")
assert "channel_id=UCW7jUEpYT_t0Gsf632d6_wQ" in url
class TestPollChannel:
def test_queues_recent_and_skips_old(self, conn, settings, channel, monkeypatch):
recent = util.today() - timedelta(days=2)
stale = util.today() - timedelta(days=400)
monkeypatch.setattr(
discovery,
"fetch_feed",
lambda url, timeout=30.0: b"ignored",
)
monkeypatch.setattr(
discovery,
"parse_entries",
lambda payload: [
{"video_id": "new1", "title": "new", "published": recent, "description": ""},
{"video_id": "old1", "title": "old", "published": stale, "description": ""},
],
)
stats = discovery.poll_channel(conn, settings, channel)
assert stats["queued"] == 1
assert stats["old"] == 1
assert videos.get(conn, "new1")["state"] == videos.PENDING
assert videos.get(conn, "old1")["state"] == videos.SKIPPED_OLD
def test_falls_back_to_channel_feed_when_uulf_is_empty(
self, conn, settings, channel, monkeypatch
):
seen_urls = []
def fake_fetch(url, timeout=30.0):
seen_urls.append(url)
return None if "playlist_id" in url else b"feed"
monkeypatch.setattr(discovery, "fetch_feed", fake_fetch)
monkeypatch.setattr(
discovery,
"parse_entries",
lambda payload: [
{
"video_id": "fb1",
"title": "fallback",
"published": util.today(),
"description": "",
}
],
)
stats = discovery.poll_channel(conn, settings, channel)
assert stats["source"] == videos.SOURCE_UC
assert any("playlist_id" in url for url in seen_urls)
assert any("channel_id" in url for url in seen_urls)
assert videos.get(conn, "fb1")["discovery_source"] == videos.SOURCE_UC
def test_feed_failure_increments_the_counter(self, conn, settings, channel, monkeypatch):
def boom(url, timeout=30.0):
raise discovery.FeedUnavailable("HTTP 503")
monkeypatch.setattr(discovery, "fetch_feed", boom)
stats = discovery.poll_channel(conn, settings, channel)
assert "error" in stats
row = conn.execute(
"SELECT * FROM channel WHERE id = ?", (channel["id"],)
).fetchone()
assert row["consecutive_poll_failures"] == 1
assert row["last_poll_ok"] == 0
def test_success_resets_the_failure_counter(self, conn, settings, channel, monkeypatch):
with conn:
conn.execute(
"UPDATE channel SET consecutive_poll_failures = 4 WHERE id = ?",
(channel["id"],),
)
monkeypatch.setattr(discovery, "fetch_feed", lambda url, timeout=30.0: b"x")
monkeypatch.setattr(discovery, "parse_entries", lambda payload: [])
discovery.poll_channel(conn, settings, channel)
row = conn.execute(
"SELECT * FROM channel WHERE id = ?", (channel["id"],)
).fetchone()
assert row["consecutive_poll_failures"] == 0
assert row["last_poll_ok"] == 1
class TestSkippedShortRepair:
"""A fallback-discovered video wrongly rejected as a Short must come back
once the authoritative UULF feed lists it."""
def _poll_with(self, monkeypatch, entry):
monkeypatch.setattr(discovery, "fetch_feed", lambda url, timeout=30.0: b"x")
monkeypatch.setattr(discovery, "parse_entries", lambda payload: [entry])
def test_repairs_a_uc_discovered_skipped_short(
self, conn, settings, channel, monkeypatch
):
add_video(
conn,
channel["id"],
"short1",
state=videos.SKIPPED_SHORT,
discovery_source=videos.SOURCE_UC,
upload_date=util.today().isoformat(),
)
self._poll_with(
monkeypatch,
{
"video_id": "short1",
"title": "t",
"published": util.today(),
"description": "",
},
)
stats = discovery.poll_channel(conn, settings, channel)
assert stats["repaired"] == 1
row = videos.get(conn, "short1")
assert row["state"] == videos.PENDING
assert row["discovery_source"] == videos.SOURCE_UULF
def test_does_not_repair_one_discovered_via_uulf(
self, conn, settings, channel, monkeypatch
):
add_video(
conn,
channel["id"],
"short2",
state=videos.SKIPPED_SHORT,
discovery_source=videos.SOURCE_UULF,
)
self._poll_with(
monkeypatch,
{"video_id": "short2", "title": "t", "published": util.today(), "description": ""},
)
discovery.poll_channel(conn, settings, channel)
assert videos.get(conn, "short2")["state"] == videos.SKIPPED_SHORT
def test_never_resurrects_a_deleted_tombstone(
self, conn, settings, channel, monkeypatch
):
add_video(
conn,
channel["id"],
"gone1",
state=videos.DELETED,
discovery_source=videos.SOURCE_UC,
)
self._poll_with(
monkeypatch,
{"video_id": "gone1", "title": "t", "published": util.today(), "description": ""},
)
discovery.poll_channel(conn, settings, channel)
assert videos.get(conn, "gone1")["state"] == videos.DELETED
class TestRescan:
def test_requeues_skipped_old_inside_the_window(self, conn, settings, channel):
inside = (util.today() - timedelta(days=5)).isoformat()
add_video(conn, channel["id"], "v1", state=videos.SKIPPED_OLD, upload_date=inside)
assert discovery.rescan_channel(conn, settings, channel) == 1
assert videos.get(conn, "v1")["state"] == videos.PENDING
def test_leaves_videos_outside_the_window_alone(self, conn, settings, channel):
outside = (util.today() - timedelta(days=200)).isoformat()
add_video(conn, channel["id"], "v2", state=videos.SKIPPED_OLD, upload_date=outside)
assert discovery.rescan_channel(conn, settings, channel) == 0
assert videos.get(conn, "v2")["state"] == videos.SKIPPED_OLD
def test_honours_a_per_channel_override(self, conn, settings, channel):
age = (util.today() - timedelta(days=30)).isoformat()
add_video(conn, channel["id"], "v3", state=videos.SKIPPED_OLD, upload_date=age)
# Default retention is 14 days, so nothing moves.
assert discovery.rescan_channel(conn, settings, channel) == 0
with conn:
conn.execute(
"UPDATE channel SET retention_days = 60 WHERE id = ?", (channel["id"],)
)
widened = conn.execute(
"SELECT * FROM channel WHERE id = ?", (channel["id"],)
).fetchone()
assert discovery.rescan_channel(conn, settings, widened) == 1
def test_never_resurrects_a_tombstone(self, conn, settings, channel):
add_video(
conn,
channel["id"],
"v4",
state=videos.DELETED,
upload_date=util.today().isoformat(),
)
assert discovery.rescan_channel(conn, settings, channel) == 0
assert videos.get(conn, "v4")["state"] == videos.DELETED
class TestEffectiveRetention:
def test_override_wins(self, settings, channel, conn):
with conn:
conn.execute(
"UPDATE channel SET retention_days = 60 WHERE id = ?", (channel["id"],)
)
row = conn.execute("SELECT * FROM channel WHERE id = ?", (channel["id"],)).fetchone()
assert discovery.effective_retention_days(settings, row) == 60
def test_falls_back_to_the_global_default(self, settings, channel):
assert discovery.effective_retention_days(settings, channel) == 14
+264
View File
@@ -0,0 +1,264 @@
"""Download worker: argument construction, rejection classification, moves."""
import json
from conftest import add_video
from youtube_automate import config, download, videos
class TestBuildArgs:
def _row(self, conn, channel, **kwargs):
add_video(conn, channel["id"], "vid1", **kwargs)
return conn.execute(
"SELECT v.*, c.dir_name, c.title AS channel_title, c.channel_id "
"FROM video v JOIN channel c ON c.id = v.channel_pk "
"WHERE v.video_id = 'vid1'"
).fetchone()
def test_sort_puts_vcodec_before_res_and_res_before_acodec(
self, conn, settings, channel
):
"""The original spec ordering selected 360p — see specs.md §6."""
args = download.build_args(settings, self._row(conn, channel))
sort = args[args.index("-S") + 1]
assert sort == "vcodec:h264,res:720,acodec:aac"
assert sort.index("vcodec") < sort.index("res") < sort.index("acodec")
def test_format_selector_caps_height(self, conn, settings, channel):
args = download.build_args(settings, self._row(conn, channel))
assert args[args.index("-f") + 1] == "bv*[height<=720]+ba/b[height<=720]"
def test_max_height_setting_is_honoured(self, conn, settings, channel):
settings.set("max_height", "480")
args = download.build_args(settings, self._row(conn, channel))
assert "height<=480" in args[args.index("-f") + 1]
assert "res:480" in args[args.index("-S") + 1]
def test_merges_to_mp4(self, conn, settings, channel):
args = download.build_args(settings, self._row(conn, channel))
assert args[args.index("--merge-output-format") + 1] == "mp4"
def test_no_match_filter_for_uulf_rows(self, conn, settings, channel):
args = download.build_args(
settings, self._row(conn, channel, discovery_source=videos.SOURCE_UULF)
)
assert "--match-filter" not in args
def test_match_filter_applied_to_fallback_rows(self, conn, settings, channel):
args = download.build_args(
settings, self._row(conn, channel, discovery_source=videos.SOURCE_UC)
)
assert "--match-filter" in args
expression = args[args.index("--match-filter") + 1]
assert "duration>?120" in expression
# The `?` forms must be used so unknown values pass rather than reject.
assert "live_status!=?is_live" in expression
assert "live_status!=?is_upcoming" in expression
assert "!was_live" in expression
def test_min_duration_setting_flows_into_the_filter(self, conn, settings, channel):
settings.set("min_duration_seconds", "60")
args = download.build_args(
settings, self._row(conn, channel, discovery_source=videos.SOURCE_UC)
)
assert "duration>?60" in args[args.index("--match-filter") + 1]
def test_subtitles_can_be_disabled(self, conn, settings, channel):
settings.set("write_subs", "false")
args = download.build_args(settings, self._row(conn, channel))
assert "--write-subs" not in args
def test_sponsorblock_marks_rather_than_removes(self, conn, settings, channel):
args = download.build_args(settings, self._row(conn, channel))
assert "--sponsorblock-mark" in args
assert "--sponsorblock-remove" not in args
assert "--embed-chapters" in args
def test_targets_the_right_video(self, conn, settings, channel):
args = download.build_args(settings, self._row(conn, channel))
assert args[-1] == "https://www.youtube.com/watch?v=vid1"
class TestRejectionClassification:
def test_not_a_rejection_when_no_marker(self):
assert download._classify_rejection({}, "downloading", "") is None
def test_upcoming_premiere_is_deferred_not_skipped(self):
outcome = download._classify_rejection(
{"live_status": "is_upcoming"}, "does not pass filter", ""
)
assert outcome == videos.DEFERRED
def test_live_is_skipped_permanently(self):
assert (
download._classify_rejection(
{"live_status": "is_live"}, "does not pass filter", ""
)
== videos.SKIPPED_LIVE
)
def test_past_livestream_is_skipped(self):
assert (
download._classify_rejection(
{"was_live": True}, "does not pass filter", ""
)
== videos.SKIPPED_LIVE
)
def test_otherwise_it_was_too_short(self):
assert (
download._classify_rejection({"duration": 30}, "does not pass filter", "")
== videos.SKIPPED_SHORT
)
def test_missing_info_json_still_classifies(self):
assert (
download._classify_rejection(None, "does not pass filter", "")
== videos.SKIPPED_SHORT
)
class TestSubtitleChoice:
def test_prefers_plain_en(self, media_root):
(config.WORK_DIR / "v.en.srt").write_text("a")
(config.WORK_DIR / "v.en-orig.srt").write_text("b")
assert download._choose_subtitle("v").name == "v.en.srt"
def test_promotes_en_orig_when_alone(self, media_root):
(config.WORK_DIR / "v.en-orig.srt").write_text("b")
assert download._choose_subtitle("v").name == "v.en-orig.srt"
def test_none_when_no_subtitles(self, media_root):
assert download._choose_subtitle("v") is None
class TestWorkDir:
def test_cleanup_removes_only_that_video(self, media_root):
(config.WORK_DIR / "keep.mp4").write_bytes(b"x")
(config.WORK_DIR / "drop.mp4").write_bytes(b"x")
(config.WORK_DIR / "drop.info.json").write_text("{}")
download.cleanup_work("drop")
assert (config.WORK_DIR / "keep.mp4").exists()
assert not (config.WORK_DIR / "drop.mp4").exists()
assert not (config.WORK_DIR / "drop.info.json").exists()
def test_recover_orphans_clears_everything_but_the_ignore_marker(self, media_root):
(config.WORK_DIR / "a.part").write_bytes(b"x")
(config.WORK_DIR / "b.mp4").write_bytes(b"x")
assert download.recover_orphans() == 2
assert [p.name for p in config.WORK_DIR.iterdir()] == [".ignore"]
class TestMoveIntoPlace:
def _row(self, conn, channel):
return conn.execute(
"SELECT v.*, c.dir_name, c.title AS channel_title, c.channel_id "
"FROM video v JOIN channel c ON c.id = v.channel_pk "
"WHERE v.video_id = 'vid1'"
).fetchone()
def _artefacts(self, video_id="vid1"):
(config.WORK_DIR / f"{video_id}.mp4").write_bytes(b"video-bytes")
(config.WORK_DIR / f"{video_id}.info.json").write_text("{}")
(config.WORK_DIR / f"{video_id}.jpg").write_bytes(b"jpg")
(config.WORK_DIR / f"{video_id}.en.srt").write_text("1\n")
def test_places_every_artefact_with_the_shared_stem(
self, conn, channel, media_root
):
add_video(conn, channel["id"], "vid1", upload_date="2026-08-11")
self._artefacts()
info = {
"upload_date": "20260811",
"title": "A Title: with colon",
"description": "plot",
"duration": 600,
}
rel_path, size = download._move_into_place(conn, self._row(conn, channel), info)
placed = (media_root / rel_path).parent
stem = "clabretro - S2026E8110 - A Title with colon [vid1]"
assert {p.name for p in placed.iterdir()} == {
f"{stem}.mp4",
f"{stem}.nfo",
f"{stem}.info.json",
f"{stem}-thumb.jpg",
f"{stem}.en.srt",
}
assert size == len(b"video-bytes")
def test_work_dir_is_emptied_of_that_video(self, conn, channel, media_root):
add_video(conn, channel["id"], "vid1", upload_date="2026-08-11")
self._artefacts()
download._move_into_place(
conn, self._row(conn, channel), {"upload_date": "20260811", "title": "t"}
)
assert list(config.WORK_DIR.glob("vid1.*")) == []
def test_database_row_records_the_result(self, conn, channel, media_root):
add_video(conn, channel["id"], "vid1", upload_date="2026-08-11")
self._artefacts()
download._move_into_place(
conn,
self._row(conn, channel),
{"upload_date": "20260811", "title": "t", "duration": 600},
)
row = videos.get(conn, "vid1")
assert row["state"] == videos.DOWNLOADED
assert row["season"] == 2026
assert row["episode"] == 8110
assert row["size_bytes"] == len(b"video-bytes")
assert row["rel_path"].endswith(".mp4")
def test_info_json_upload_date_beats_the_feed_date(
self, conn, channel, media_root
):
add_video(conn, channel["id"], "vid1", upload_date="2026-01-01")
self._artefacts()
download._move_into_place(
conn, self._row(conn, channel), {"upload_date": "20260811", "title": "t"}
)
row = videos.get(conn, "vid1")
assert row["upload_date"] == "2026-08-11"
assert row["episode"] == 8110
def test_missing_media_file_raises(self, conn, channel, media_root):
add_video(conn, channel["id"], "vid1", upload_date="2026-08-11")
(config.WORK_DIR / "vid1.info.json").write_text("{}")
try:
download._move_into_place(
conn, self._row(conn, channel), {"upload_date": "20260811", "title": "t"}
)
except FileNotFoundError:
pass
else: # pragma: no cover
raise AssertionError("expected FileNotFoundError")
def test_written_nfo_matches_the_episode_number(self, conn, channel, media_root):
add_video(conn, channel["id"], "vid1", upload_date="2026-08-11")
self._artefacts()
rel_path, _ = download._move_into_place(
conn,
self._row(conn, channel),
{"upload_date": "20260811", "title": "t", "description": "d", "duration": 60},
)
nfo_path = (media_root / rel_path).with_suffix(".nfo")
content = nfo_path.read_text()
assert "<episode>8110</episode>" in content
assert "S2026E8110" in nfo_path.name
class TestReadInfoJson:
def test_returns_none_when_absent(self, media_root):
assert download._read_info_json("nope") is None
def test_returns_none_on_corrupt_json(self, media_root):
(config.WORK_DIR / "v.info.json").write_text("{not json")
assert download._read_info_json("v") is None
def test_parses_valid_json(self, media_root):
(config.WORK_DIR / "v.info.json").write_text(json.dumps({"title": "x"}))
assert download._read_info_json("v")["title"] == "x"
+128
View File
@@ -0,0 +1,128 @@
from datetime import date
import pytest
from youtube_automate import naming
class TestSanitise:
@pytest.mark.parametrize(
"raw, expected",
[
("Hermitcraft S11#11: Expanding Business", "Hermitcraft S11#11 Expanding Business"),
("A/B", "A B"),
('Say "hello" <now>', "Say hello now"),
("path\\to\\thing", "path to thing"),
("what? really * | yes", "what really yes"),
(" .leading and trailing. ", "leading and trailing"),
("line one\nline two", "line one line two"),
("tabs\tand\r\nnewlines", "tabs and newlines"),
],
)
def test_removes_illegal_characters(self, raw, expected):
assert naming.sanitize_component(raw) == expected
def test_empty_input_is_empty(self):
assert naming.sanitize_component("") == ""
assert naming.sanitize_component(None) == ""
def test_title_that_is_only_illegal_characters_collapses_to_empty(self):
assert naming.sanitize_component("///???") == ""
def test_truncates_to_120_characters(self):
long = "word " * 60
result = naming.sanitize_component(long)
assert len(result) <= naming.MAX_TITLE_LEN
def test_truncation_prefers_a_word_boundary(self):
text = "alpha bravo charlie delta echo foxtrot golf hotel india juliet " * 3
result = naming.sanitize_component(text)
assert not result.endswith(" ")
# Should not cut mid-word when a boundary is available late enough.
assert result == result.rstrip()
assert " " in result
def test_hard_cuts_when_no_late_word_boundary_exists(self):
text = "a" + "b" * 400
result = naming.sanitize_component(text)
assert len(result) == naming.MAX_TITLE_LEN
class TestEpisodeNumbering:
@pytest.mark.parametrize(
"day, ordinal, expected",
[
(date(2026, 8, 11), 0, 8110),
(date(2026, 8, 11), 1, 8111),
(date(2026, 8, 12), 0, 8120),
(date(2026, 1, 1), 0, 1010),
(date(2026, 12, 31), 0, 12310),
(date(2026, 12, 31), 9, 12319),
],
)
def test_episode_number(self, day, ordinal, expected):
assert naming.episode_number(day, ordinal) == expected
def test_ordinal_is_clamped_at_nine(self):
assert naming.episode_number(date(2026, 8, 11), 12) == 8119
def test_negative_ordinal_clamps_to_zero(self):
assert naming.episode_number(date(2026, 8, 11), -3) == 8110
def test_numbers_sort_chronologically_across_the_year(self):
days = [date(2026, 1, 1), date(2026, 6, 15), date(2026, 8, 11), date(2026, 12, 31)]
numbers = [naming.episode_number(day, 0) for day in days]
assert numbers == sorted(numbers)
def test_episode_range_covers_ten_slots(self):
low, high = naming.episode_range(date(2026, 8, 11))
assert (low, high) == (8110, 8119)
def test_season_is_the_upload_year(self):
assert naming.season_for(date(2026, 8, 11)) == 2026
class TestParseUploadDate:
def test_accepts_ytdlp_compact_form(self):
assert naming.parse_upload_date("20260811") == date(2026, 8, 11)
def test_accepts_iso_form(self):
assert naming.parse_upload_date("2026-08-11") == date(2026, 8, 11)
def test_accepts_iso_timestamp(self):
assert naming.parse_upload_date("2026-08-11T12:00:00+00:00") == date(2026, 8, 11)
def test_passes_through_a_date(self):
assert naming.parse_upload_date(date(2026, 8, 11)) == date(2026, 8, 11)
def test_rejects_nonsense(self):
with pytest.raises(ValueError):
naming.parse_upload_date("not a date")
class TestBasename:
def test_includes_video_id_for_uniqueness(self):
stem = naming.basename("clabretro", 2026, 8110, "A Title", "dQw4w9WgXcQ")
assert stem == "clabretro - S2026E8110 - A Title [dQw4w9WgXcQ]"
def test_episode_is_unpadded_to_match_the_nfo(self):
stem = naming.basename("c", 2026, 8110, "t", "id")
assert "S2026E8110" in stem
assert "E08110" not in stem
def test_falls_back_to_video_id_when_the_title_sanitises_away(self):
stem = naming.basename("c", 2026, 8110, "///", "dQw4w9WgXcQ")
assert stem.endswith("dQw4w9WgXcQ [dQw4w9WgXcQ]")
def test_two_videos_same_title_differ_by_id(self):
one = naming.basename("c", 2026, 8110, "Same", "aaaaaaaaaaa")
two = naming.basename("c", 2026, 8111, "Same", "bbbbbbbbbbb")
assert one != two
class TestChannelDirName:
def test_sanitises_the_title(self):
assert naming.channel_dir_name("Tom / Jerry", "UCabc") == "Tom Jerry"
def test_falls_back_to_channel_id_when_title_is_unusable(self):
assert naming.channel_dir_name("???", "UCabc") == "UCabc"
+94
View File
@@ -0,0 +1,94 @@
import xml.etree.ElementTree as ET
from youtube_automate import nfo
HOSTILE = (
"Ampersands & angle <brackets> and \"quotes\"\n"
"control chars: \x00\x07\x1b\n"
"emoji 🎬 and em-dash — and links https://example.com/?a=1&b=2"
)
class TestEpisodeNfo:
def build(self, **overrides):
kwargs = dict(
title="Video Title",
show_title="Some Channel",
season=2026,
episode=8110,
plot="A plot.",
aired="2026-08-11",
duration_seconds=762,
video_id="dQw4w9WgXcQ",
)
kwargs.update(overrides)
return nfo.episode_nfo(**kwargs)
def test_is_well_formed_xml(self):
root = ET.fromstring(self.build())
assert root.tag == "episodedetails"
def test_hostile_description_still_parses(self):
root = ET.fromstring(self.build(plot=HOSTILE))
plot = root.findtext("plot")
assert "&" in plot and "<brackets>" in plot
assert "🎬" in plot
def test_control_characters_are_stripped(self):
plot = ET.fromstring(self.build(plot=HOSTILE)).findtext("plot")
for bad in ("\x00", "\x07", "\x1b"):
assert bad not in plot
def test_newlines_are_preserved(self):
plot = ET.fromstring(self.build(plot="one\ntwo")).findtext("plot")
assert plot == "one\ntwo"
def test_runtime_is_rounded_minutes(self):
assert ET.fromstring(self.build(duration_seconds=762)).findtext("runtime") == "13"
def test_short_video_still_gets_at_least_one_minute(self):
assert ET.fromstring(self.build(duration_seconds=20)).findtext("runtime") == "1"
def test_runtime_omitted_when_duration_unknown(self):
assert ET.fromstring(self.build(duration_seconds=None)).find("runtime") is None
def test_unique_id_marks_youtube_as_default(self):
unique = ET.fromstring(self.build()).find("uniqueid")
assert unique.get("type") == "youtube"
assert unique.get("default") == "true"
assert unique.text == "dQw4w9WgXcQ"
def test_season_and_episode_are_present(self):
root = ET.fromstring(self.build())
assert root.findtext("season") == "2026"
assert root.findtext("episode") == "8110"
def test_title_keeps_characters_that_the_filename_strips(self):
root = ET.fromstring(self.build(title="Hermitcraft S11#11: Expanding Business"))
assert root.findtext("title") == "Hermitcraft S11#11: Expanding Business"
def test_empty_plot_does_not_break(self):
assert ET.fromstring(self.build(plot=None)).find("plot") is not None
class TestTvshowNfo:
def test_well_formed_and_carries_channel_id(self):
root = ET.fromstring(nfo.tvshow_nfo("clabretro", HOSTILE, "UCabc123"))
assert root.tag == "tvshow"
assert root.findtext("title") == "clabretro"
assert root.findtext("studio") == "YouTube"
assert root.find("uniqueid").text == "UCabc123"
class TestWrite:
def test_write_is_atomic_and_leaves_no_temp_file(self, tmp_path):
target = tmp_path / "sub" / "tvshow.nfo"
nfo.write(target, b"<tvshow/>")
assert target.read_bytes() == b"<tvshow/>"
assert list(tmp_path.rglob("*.tmp")) == []
def test_overwrites_existing(self, tmp_path):
target = tmp_path / "tvshow.nfo"
nfo.write(target, b"<a/>")
nfo.write(target, b"<b/>")
assert target.read_bytes() == b"<b/>"
+186
View File
@@ -0,0 +1,186 @@
"""Retention: candidate selection, artefact deletion, pruning, disk cap."""
from datetime import timedelta
from conftest import add_video
from youtube_automate import config, reap, util, videos
def _place(media_root, channel_dir, season, stem):
"""Create a downloaded video's full set of artefacts on disk."""
season_dir = media_root / channel_dir / f"Season {season}"
season_dir.mkdir(parents=True, exist_ok=True)
(season_dir / f"{stem}.mp4").write_bytes(b"video-bytes")
(season_dir / f"{stem}.nfo").write_text("<episodedetails/>")
(season_dir / f"{stem}.info.json").write_text("{}")
(season_dir / f"{stem}-thumb.jpg").write_bytes(b"jpg")
(season_dir / f"{stem}.en.srt").write_text("1\n")
return f"{channel_dir}/Season {season}/{stem}.mp4"
def _downloaded(conn, channel, media_root, video_id, upload_date, size=1024):
"""Place artefacts and record the row.
`size` is only ever read back out of the database (the disk-cap arithmetic
uses `size_bytes`), so the files on disk stay tiny no matter how large a
size the test claims.
"""
stem = f"clabretro - S2026E8110 - Title [{video_id}]"
rel = _place(media_root, channel["dir_name"], 2026, stem)
add_video(conn, channel["id"], video_id, upload_date=upload_date)
videos.mark_downloaded(
conn, video_id, rel_path=rel, size_bytes=size, season=2026, episode=8110,
upload_date=upload_date, duration=100, title="Title",
)
return rel
class TestCandidates:
def test_selects_only_videos_past_the_window(self, conn, settings, channel, media_root):
fresh = (util.today() - timedelta(days=3)).isoformat()
stale = (util.today() - timedelta(days=30)).isoformat()
_downloaded(conn, channel, media_root, "fresh", fresh)
_downloaded(conn, channel, media_root, "stale", stale)
due = [row["video_id"] for row in reap.candidates(conn, settings)]
assert due == ["stale"]
def test_per_channel_override_widens_the_window(
self, conn, settings, channel, media_root
):
age = (util.today() - timedelta(days=30)).isoformat()
_downloaded(conn, channel, media_root, "v1", age)
assert len(reap.candidates(conn, settings)) == 1
with conn:
conn.execute(
"UPDATE channel SET retention_days = 60 WHERE id = ?", (channel["id"],)
)
assert reap.candidates(conn, settings) == []
def test_per_channel_override_narrows_the_window(
self, conn, settings, channel, media_root
):
age = (util.today() - timedelta(days=5)).isoformat()
_downloaded(conn, channel, media_root, "v1", age)
assert reap.candidates(conn, settings) == []
with conn:
conn.execute(
"UPDATE channel SET retention_days = 2 WHERE id = ?", (channel["id"],)
)
assert len(reap.candidates(conn, settings)) == 1
def test_ignores_videos_that_are_not_downloaded(self, conn, settings, channel):
old = (util.today() - timedelta(days=99)).isoformat()
add_video(conn, channel["id"], "p", upload_date=old, state=videos.PENDING)
add_video(conn, channel["id"], "d", upload_date=old, state=videos.DELETED)
assert reap.candidates(conn, settings) == []
class TestDeletion:
def test_removes_every_sidecar(self, conn, settings, channel, media_root, monkeypatch):
monkeypatch.setattr(config, "MEDIA_ROOT", media_root)
old = (util.today() - timedelta(days=30)).isoformat()
rel = _downloaded(conn, channel, media_root, "v1", old)
season_dir = (media_root / rel).parent
reap.run(conn, settings)
assert not season_dir.exists()
def test_leaves_a_tombstone(self, conn, settings, channel, media_root, monkeypatch):
monkeypatch.setattr(config, "MEDIA_ROOT", media_root)
old = (util.today() - timedelta(days=30)).isoformat()
_downloaded(conn, channel, media_root, "v1", old)
reap.run(conn, settings)
row = videos.get(conn, "v1")
assert row["state"] == videos.DELETED
assert row["rel_path"] is None
def test_keeps_the_channel_directory_and_its_artwork(
self, conn, settings, channel, media_root, monkeypatch
):
"""Deleting the channel dir would make an active subscription vanish
from Jellyfin and reappear later."""
monkeypatch.setattr(config, "MEDIA_ROOT", media_root)
channel_dir = media_root / channel["dir_name"]
channel_dir.mkdir(parents=True, exist_ok=True)
(channel_dir / "tvshow.nfo").write_text("<tvshow/>")
(channel_dir / "poster.jpg").write_bytes(b"jpg")
old = (util.today() - timedelta(days=30)).isoformat()
_downloaded(conn, channel, media_root, "v1", old)
reap.run(conn, settings)
assert channel_dir.is_dir()
assert (channel_dir / "tvshow.nfo").exists()
assert (channel_dir / "poster.jpg").exists()
def test_does_not_prune_a_season_that_still_has_videos(
self, conn, settings, channel, media_root, monkeypatch
):
monkeypatch.setattr(config, "MEDIA_ROOT", media_root)
old = (util.today() - timedelta(days=30)).isoformat()
fresh = (util.today() - timedelta(days=1)).isoformat()
rel = _downloaded(conn, channel, media_root, "old1", old)
_downloaded(conn, channel, media_root, "new1", fresh)
reap.run(conn, settings)
season_dir = (media_root / rel).parent
assert season_dir.is_dir()
assert list(season_dir.glob("*new1*"))
assert not list(season_dir.glob("*old1*"))
def test_only_deletes_files_matching_the_stem(
self, conn, settings, channel, media_root, monkeypatch
):
monkeypatch.setattr(config, "MEDIA_ROOT", media_root)
old = (util.today() - timedelta(days=30)).isoformat()
rel = _downloaded(conn, channel, media_root, "v1", old)
season_dir = (media_root / rel).parent
bystander = season_dir / "unrelated file.txt"
bystander.write_text("keep me")
reap.run(conn, settings)
assert bystander.exists()
class TestDiskCap:
def test_disabled_by_default(self, conn, settings, channel, media_root):
_downloaded(conn, channel, media_root, "v1", util.today().isoformat(), size=10**6)
assert reap.disk_cap_evictions(conn, settings) == []
def test_evicts_oldest_first_until_under_the_cap(
self, conn, settings, channel, media_root
):
gigabyte = 1024**3
for index, day in enumerate((10, 5, 1)):
_downloaded(
conn,
channel,
media_root,
f"v{index}",
(util.today() - timedelta(days=day)).isoformat(),
size=gigabyte,
)
settings.set("disk_cap_gb", "2")
evicted = [row["video_id"] for row in reap.disk_cap_evictions(conn, settings)]
assert evicted == ["v0"]
def test_nothing_evicted_when_under_the_cap(self, conn, settings, channel, media_root):
_downloaded(conn, channel, media_root, "v1", util.today().isoformat(), size=1024)
settings.set("disk_cap_gb", "5")
assert reap.disk_cap_evictions(conn, settings) == []
class TestEffectiveRetention:
def test_override_wins(self, settings):
assert reap.effective_retention(settings, 60) == 60
def test_none_falls_back_to_global(self, settings):
assert reap.effective_retention(settings, None) == 14
def test_zero_falls_back_to_global(self, settings):
assert reap.effective_retention(settings, 0) == 14
+96
View File
@@ -0,0 +1,96 @@
"""Locking and crash recovery."""
import multiprocessing
import pytest
from conftest import add_video
from youtube_automate import config, download, runner, videos
def _hold_lock(path, started, release):
from youtube_automate import runner as runner_module
with runner_module.exclusive_lock(path):
started.set()
release.wait(timeout=30)
class TestExclusiveLock:
def test_acquires_when_free(self, tmp_path):
with runner.exclusive_lock(tmp_path / "run.lock"):
pass # no exception is the assertion
def test_can_be_reacquired_after_release(self, tmp_path):
path = tmp_path / "run.lock"
with runner.exclusive_lock(path):
pass
with runner.exclusive_lock(path):
pass
def test_second_holder_is_refused(self, tmp_path):
path = tmp_path / "run.lock"
started = multiprocessing.Event()
release = multiprocessing.Event()
holder = multiprocessing.Process(
target=_hold_lock, args=(path, started, release)
)
holder.start()
try:
assert started.wait(timeout=15), "helper never acquired the lock"
with pytest.raises(runner.AlreadyRunning):
with runner.exclusive_lock(path):
pass
finally:
release.set()
holder.join(timeout=15)
def test_creates_the_parent_directory(self, tmp_path):
path = tmp_path / "nested" / "deeper" / "run.lock"
with runner.exclusive_lock(path):
assert path.exists()
class TestRecover:
def test_requeues_downloading_rows(self, conn, channel, media_root):
add_video(conn, channel["id"], "a", state=videos.DOWNLOADING)
result = runner.recover(conn)
assert result["requeued"] == 1
assert videos.get(conn, "a")["state"] == videos.PENDING
def test_clears_work_dir_orphans(self, conn, media_root):
(config.WORK_DIR / "half.part").write_bytes(b"x")
(config.WORK_DIR / "half.mp4").write_bytes(b"x")
result = runner.recover(conn)
assert result["orphans"] == 2
assert [p.name for p in config.WORK_DIR.iterdir()] == [".ignore"]
def test_is_a_no_op_on_a_clean_state(self, conn, media_root):
assert runner.recover(conn) == {"requeued": 0, "orphans": 0}
def test_leaves_the_ignore_marker_in_place(self, conn, media_root):
download.recover_orphans()
assert (config.WORK_DIR / ".ignore").exists()
class TestSummarise:
def test_reports_each_stage(self):
text = runner.summarise(
{
"poll": {"queued": 3, "repaired": 1, "failed": 0},
"download": {videos.DOWNLOADED: 2, videos.FAILED: 1},
"reap": {"deleted": 4, "evicted": 0},
}
)
assert "discovered=3" in text
assert "repaired=1" in text
assert "downloaded=2" in text
assert "failed=1" in text
assert "reaped=4" in text
def test_surfaces_a_download_error(self):
text = runner.summarise({"download": {"error": "provider down"}})
assert "ERROR=provider down" in text
def test_handles_empty_input(self):
assert "downloaded=0" in runner.summarise({})
+110
View File
@@ -0,0 +1,110 @@
"""Typed settings accessors and form validation."""
import pytest
from youtube_automate import settings as settings_module
from youtube_automate.settings import DEFAULTS, Settings, validate, validate_all
class TestAccessors:
def test_missing_key_returns_the_default(self, settings):
assert settings.get_int("retention_days") == 14
assert settings.get_str("sub_langs") == "en.*"
assert settings.get_bool("write_subs") is True
def test_unknown_key_never_raises(self, settings):
assert settings.get_str("no_such_key") == ""
assert settings.get_int("no_such_key") == 0
def test_set_then_get(self, settings):
settings.set("retention_days", "30")
assert settings.get_int("retention_days") == 30
def test_set_overwrites(self, settings):
settings.set("retention_days", "30")
settings.set("retention_days", "45")
assert settings.get_int("retention_days") == 45
def test_corrupt_integer_falls_back_to_the_default(self, settings):
settings.set("retention_days", "not a number")
assert settings.get_int("retention_days") == 14
@pytest.mark.parametrize("truthy", ["true", "True", "1", "yes", "on"])
def test_bool_truthy_forms(self, settings, truthy):
settings.set("write_subs", truthy)
assert settings.get_bool("write_subs") is True
@pytest.mark.parametrize("falsy", ["false", "False", "0", "no", "off", ""])
def test_bool_falsy_forms(self, settings, falsy):
settings.set("write_subs", falsy)
assert settings.get_bool("write_subs") is False
def test_corrupt_bool_falls_back_to_the_default(self, settings):
settings.set("write_subs", "maybe")
assert settings.get_bool("write_subs") is True
def test_all_editable_covers_every_default(self, settings):
values = settings.all_editable()
assert set(values) == set(DEFAULTS)
def test_secrets_are_not_editable(self):
for key in settings_module.SECRET_KEYS:
assert key not in settings_module.EDITABLE
class TestValidation:
@pytest.mark.parametrize(
"key, value",
[
("retention_days", "14"),
("backfill_days", "0"),
("max_height", "720"),
("min_duration_seconds", "120"),
("disk_cap_gb", "0"),
("write_subs", "true"),
("sponsorblock_mark", "false"),
("jellyfin_url", "http://127.0.0.1:8096"),
("pot_provider_url", "https://example.com:4416"),
("sub_langs", "en.*"),
],
)
def test_accepts_good_values(self, key, value):
ok, _ = validate(key, value)
assert ok
@pytest.mark.parametrize(
"key, value",
[
("retention_days", "abc"),
("retention_days", "0"),
("retention_days", "-5"),
("max_height", "10"),
("min_duration_seconds", "-1"),
("write_subs", "maybe"),
("jellyfin_url", "not-a-url"),
("jellyfin_url", "ftp://host/"),
("jellyfin_url", "http://"),
("sub_langs", ""),
],
)
def test_rejects_bad_values(self, key, value):
ok, message = validate(key, value)
assert not ok
assert message
def test_validate_all_reports_each_bad_field(self):
errors = validate_all(
{"retention_days": "abc", "max_height": "720", "jellyfin_url": "nope"}
)
assert set(errors) == {"retention_days", "jellyfin_url"}
def test_validate_all_ignores_unknown_keys(self):
assert validate_all({"not_a_setting": "x"}) == {}
def test_empty_api_key_is_acceptable(self):
ok, _ = validate("jellyfin_api_key", "")
assert ok
def test_whitespace_is_tolerated(self):
ok, _ = validate("retention_days", " 21 ")
assert ok
+189
View File
@@ -0,0 +1,189 @@
"""The video state machine and episode assignment."""
from datetime import date
from conftest import add_video
from youtube_automate import videos
class TestNextEpisode:
def test_first_video_of_the_day_gets_the_base_number(self, conn, channel):
add_video(conn, channel["id"], "a")
season, episode = videos.next_episode(
conn, channel["id"], date(2026, 8, 11), "a"
)
assert (season, episode) == (2026, 8110)
def test_second_video_increments_the_ordinal(self, conn, channel):
add_video(conn, channel["id"], "a")
add_video(conn, channel["id"], "b")
videos.mark_downloaded(
conn, "a", rel_path="x", size_bytes=1, season=2026, episode=8110,
upload_date="2026-08-11", duration=1, title="t",
)
season, episode = videos.next_episode(
conn, channel["id"], date(2026, 8, 11), "b"
)
assert episode == 8111
def test_ordinal_is_computed_from_the_database_not_the_batch(self, conn, channel):
"""Stability across runs: a video keeps its slot even if others are
assigned in a different order later."""
for name, ep in (("a", 8110), ("b", 8111)):
add_video(conn, channel["id"], name)
videos.mark_downloaded(
conn, name, rel_path="x", size_bytes=1, season=2026, episode=ep,
upload_date="2026-08-11", duration=1, title="t",
)
add_video(conn, channel["id"], "c")
_, episode = videos.next_episode(conn, channel["id"], date(2026, 8, 11), "c")
assert episode == 8112
def test_a_different_day_starts_fresh(self, conn, channel):
add_video(conn, channel["id"], "a")
videos.mark_downloaded(
conn, "a", rel_path="x", size_bytes=1, season=2026, episode=8110,
upload_date="2026-08-11", duration=1, title="t",
)
add_video(conn, channel["id"], "b")
_, episode = videos.next_episode(conn, channel["id"], date(2026, 8, 12), "b")
assert episode == 8120
def test_another_channel_does_not_share_the_numbering(self, conn, channel):
with conn:
conn.execute(
"INSERT INTO channel (channel_id, title, dir_name, added_at) "
"VALUES ('UCother', 'Other', 'Other', '2026-01-01')"
)
other = conn.execute(
"SELECT id FROM channel WHERE dir_name = 'Other'"
).fetchone()["id"]
add_video(conn, channel["id"], "a")
videos.mark_downloaded(
conn, "a", rel_path="x", size_bytes=1, season=2026, episode=8110,
upload_date="2026-08-11", duration=1, title="t",
)
add_video(conn, other, "b")
_, episode = videos.next_episode(conn, other, date(2026, 8, 11), "b")
assert episode == 8110
def test_clamps_at_the_tenth_upload_of_a_day(self, conn, channel):
for index in range(10):
name = f"v{index}"
add_video(conn, channel["id"], name)
videos.mark_downloaded(
conn, name, rel_path="x", size_bytes=1, season=2026,
episode=8110 + index, upload_date="2026-08-11", duration=1, title="t",
)
add_video(conn, channel["id"], "overflow")
_, episode = videos.next_episode(
conn, channel["id"], date(2026, 8, 11), "overflow"
)
assert episode == 8119
def test_reassigning_the_same_video_is_stable(self, conn, channel):
add_video(conn, channel["id"], "a")
videos.mark_downloaded(
conn, "a", rel_path="x", size_bytes=1, season=2026, episode=8110,
upload_date="2026-08-11", duration=1, title="t",
)
# Its own row must be excluded, so it gets the same slot back.
_, episode = videos.next_episode(conn, channel["id"], date(2026, 8, 11), "a")
assert episode == 8110
class TestQueue:
def test_claim_returns_pending(self, conn, channel):
add_video(conn, channel["id"], "a")
assert [row["video_id"] for row in videos.claim_pending(conn, 5)] == ["a"]
def test_claim_includes_failed_with_attempts_left(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.FAILED)
with conn:
conn.execute("UPDATE video SET attempts = 2 WHERE video_id = 'a'")
assert len(videos.claim_pending(conn, 5)) == 1
def test_claim_excludes_exhausted_failures(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.FAILED)
with conn:
conn.execute("UPDATE video SET attempts = 5 WHERE video_id = 'a'")
assert videos.claim_pending(conn, 5) == []
def test_claim_excludes_terminal_states(self, conn, channel):
for index, state in enumerate(
(videos.DELETED, videos.SKIPPED_LIVE, videos.SKIPPED_OLD,
videos.SKIPPED_SHORT, videos.DOWNLOADED)
):
add_video(conn, channel["id"], f"v{index}", state=state)
assert videos.claim_pending(conn, 5) == []
def test_claim_is_oldest_first(self, conn, channel):
add_video(conn, channel["id"], "new", upload_date="2026-08-10")
add_video(conn, channel["id"], "old", upload_date="2026-08-01")
assert [row["video_id"] for row in videos.claim_pending(conn, 5)] == ["old", "new"]
def test_limit_is_respected(self, conn, channel):
for index in range(5):
add_video(conn, channel["id"], f"v{index}")
assert len(videos.claim_pending(conn, 5, limit=2)) == 2
class TestCrashRecovery:
def test_downloading_rows_return_to_pending(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.DOWNLOADING)
assert videos.recover_downloading(conn) == 1
assert videos.get(conn, "a")["state"] == videos.PENDING
def test_other_states_are_untouched(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.DOWNLOADED)
add_video(conn, channel["id"], "b", state=videos.DELETED)
assert videos.recover_downloading(conn) == 0
assert videos.get(conn, "a")["state"] == videos.DOWNLOADED
assert videos.get(conn, "b")["state"] == videos.DELETED
class TestFailures:
def test_attempts_accumulate(self, conn, channel):
add_video(conn, channel["id"], "a")
videos.record_failure(conn, "a", "boom", 5)
videos.record_failure(conn, "a", "boom", 5)
row = videos.get(conn, "a")
assert row["attempts"] == 2
assert row["state"] == videos.FAILED
assert row["last_error"] == "boom"
def test_exhaustion_is_reported(self, conn, channel):
add_video(conn, channel["id"], "a")
for _ in range(4):
videos.record_failure(conn, "a", "boom", 5)
assert videos.record_failure(conn, "a", "boom", 5) == "exhausted"
class TestTombstone:
def test_mark_deleted_clears_the_path_but_keeps_the_row(self, conn, channel):
add_video(conn, channel["id"], "a")
videos.mark_downloaded(
conn, "a", rel_path="x/y.mp4", size_bytes=10, season=2026, episode=8110,
upload_date="2026-08-11", duration=1, title="t",
)
videos.mark_deleted(conn, "a")
row = videos.get(conn, "a")
assert row is not None
assert row["state"] == videos.DELETED
assert row["rel_path"] is None
assert row["deleted_at"]
def test_insert_never_overwrites_an_existing_row(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.DELETED)
add_video(conn, channel["id"], "a", state=videos.PENDING)
assert videos.get(conn, "a")["state"] == videos.DELETED
class TestQueueDepth:
def test_counts_only_work_in_progress(self, conn, channel):
add_video(conn, channel["id"], "a", state=videos.PENDING)
add_video(conn, channel["id"], "b", state=videos.DOWNLOADING)
add_video(conn, channel["id"], "c", state=videos.FAILED)
add_video(conn, channel["id"], "d", state=videos.DOWNLOADED)
assert videos.queue_depth(conn) == 3