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:
@@ -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()
|
||||
Vendored
+37
@@ -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 & a cheap printer. Part 1/3 <of a series>.</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>
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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/>"
|
||||
@@ -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
|
||||
@@ -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({})
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user