Phases 1-4 of plan.md §13. Forked from youtube-automate as planned rather than
written from scratch: naming, NFO, auth, the admin UI, settings and the DB layer
came across largely unchanged, download.py is gone, and the pieces that only make
sense for a streaming library are new.
api.py YouTube Data API v3 client. The whole metadata path.
strm.py Materialising: a .strm, an .nfo and a thumbnail. Replaces the
330-line download.py, because the job is writing a URL to a file.
subsync.py The subscription mirror, most of which is refusals.
reap.py Retention, rewritten around the 30-day window and min_keep_videos.
discovery.py RSS polling plus an API-backed, resumable, bounded backfill.
proxy/ The verified PoC, moved in with a systemd unit.
330 tests, all passing, no network and no yt-dlp in any of them. The suite leans
towards the failure paths, because that is where this design can actually hurt
someone: a 403 that looks like an unsubscribe, a video that ages out and comes
back, a title that never arrives. tests/test_proxy.py replaces the two standalone
scripts under proxy/ and now drives the real make_handler(mgr, ...) rather than the
PoC's make_handler(path, done), so routing and video-id validation are covered too.
Ran it end to end against the live API and it found three real bugs.
The first was mine and the tests caught it: strm.remove pruned empty directories
up to the media root, so a channel directory whose tvshow.nfo happened to be
missing would be deleted along with the season. It only looked safe because
tvshow.nfo normally stops the walk. The prune boundary is now the channel
directory explicitly.
The other two only showed up against real data, and they compounded. The backfill
inserted rows with no title and left the RSS poll to fill them in — but RSS returns
15 entries, which for Pitch Side spans 23 days against a 30-day window, so five of
twenty episodes were named after their video ids. Worse, strm.materialise wrote
that fallback back to the database as the title, which made the row look titled and
permanently disabled the repair path. Both fixed: playlistItems.list now requests
snippet as well as contentDetails, which costs the same single quota unit and
carries the title alongside the exact publish date, and the fallback is used for the
filename without being persisted. A title that does arrive late now also removes the
badly-named files and re-queues, so the episode is rewritten rather than keeping its
video-id name forever. Verified against the live API: all twenty Pitch Side episodes
now carry real titles.
Measured on the real account: 119 subscriptions queued for approval and none added
on the first sync, then a two-channel run backfilled and materialised 26 episodes in
under seven seconds.
Two deliberate departures from plan.md, both recorded there:
min_keep_videos defaults to 5 rather than being left as an open question. Without
it 52 of 117 measured channels are empty Jellyfin series that flicker in and out as
their single video crosses the retention line, and the plan already recommended it.
The Jellyfin refresh is a bare /Library/Refresh with a comment explaining why it
must stay that way. A normal scan makes zero media probes; FullRefresh does probe,
and at 400 episodes that is 400 cold starts.
Not yet done: no systemd units are installed (needs root — deploy/deploy.sh), the
admin UI has no routes for sources or the approval queue yet, and nothing has been
pointed at the real media root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
6.8 KiB
Python
202 lines
6.8 KiB
Python
"""Retention: the window, the min-keep floor, and the tombstones.
|
|
|
|
These are the tests that matter most in this suite. Retention is the only part of
|
|
ytstream that deletes things, and two of its rules exist because of measured
|
|
behaviour rather than taste:
|
|
|
|
* `min_keep_videos` exists because 52 of 117 real channels upload nothing in 30
|
|
days and would otherwise be empty, flickering Jellyfin series.
|
|
* the `aged_out` tombstone exists because without it the poller re-materialises
|
|
everything the sweep just deleted, forever.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
|
|
import pytest
|
|
|
|
from ytstream import discovery, reap, strm, util, videos
|
|
|
|
from conftest import add_channel, add_video
|
|
|
|
|
|
def _old(days: int) -> str:
|
|
return (util.today() - timedelta(days=days)).isoformat()
|
|
|
|
|
|
@pytest.fixture()
|
|
def materialised(conn, settings, media_root, channel):
|
|
"""Put videos on disk with a spread of ages. Returns their ids, newest first."""
|
|
counter = {"n": 0}
|
|
|
|
def build(ages: list[int], chan=None):
|
|
chan = chan or channel
|
|
ids = []
|
|
for age in ages:
|
|
video_id = f"vid{counter['n']:08d}"
|
|
counter["n"] += 1
|
|
add_video(conn, chan["id"], video_id, upload_date=_old(age))
|
|
strm.materialise(conn, settings, chan, videos.get(conn, video_id))
|
|
ids.append(video_id)
|
|
return ids
|
|
|
|
return build
|
|
|
|
|
|
def test_video_inside_the_window_is_kept(conn, settings, materialised):
|
|
materialised([1])
|
|
assert reap.candidates(conn, settings) == []
|
|
|
|
|
|
def test_video_outside_the_window_is_deleted(conn, settings, materialised, media_root):
|
|
settings.set("min_keep_videos", "0")
|
|
[video_id] = materialised([99])
|
|
|
|
path = media_root / videos.get(conn, video_id)["rel_path"]
|
|
assert path.exists()
|
|
|
|
result = reap.run(conn, settings)
|
|
|
|
assert result["aged_out"] == 1
|
|
assert not path.exists()
|
|
assert videos.get(conn, video_id)["state"] == videos.AGED_OUT
|
|
|
|
|
|
def test_deleting_removes_the_sidecars_too(conn, settings, materialised, media_root):
|
|
settings.set("min_keep_videos", "0")
|
|
[video_id] = materialised([99])
|
|
strm_path = media_root / videos.get(conn, video_id)["rel_path"]
|
|
stem = strm_path.name[: -len(".strm")]
|
|
nfo_path = strm_path.with_name(stem + ".nfo")
|
|
assert nfo_path.exists()
|
|
|
|
reap.run(conn, settings)
|
|
|
|
assert not nfo_path.exists()
|
|
assert not strm_path.exists()
|
|
|
|
|
|
def test_min_keep_videos_protects_the_newest_regardless_of_age(
|
|
conn, settings, materialised
|
|
):
|
|
"""The measured common case: a channel whose only videos are all ancient."""
|
|
settings.set("min_keep_videos", "5")
|
|
materialised([400, 500, 600, 700, 800])
|
|
|
|
assert reap.candidates(conn, settings) == []
|
|
assert reap.run(conn, settings)["aged_out"] == 0
|
|
|
|
|
|
def test_min_keep_videos_releases_once_enough_newer_ones_exist(
|
|
conn, settings, materialised
|
|
):
|
|
"""Six old videos with a floor of five: exactly the oldest one goes."""
|
|
settings.set("min_keep_videos", "5")
|
|
ids = materialised([100, 200, 300, 400, 500, 600])
|
|
oldest = ids[-1]
|
|
|
|
assert reap.run(conn, settings)["aged_out"] == 1
|
|
|
|
assert videos.get(conn, oldest)["state"] == videos.AGED_OUT
|
|
for kept in ids[:-1]:
|
|
assert videos.get(conn, kept)["state"] == videos.MATERIALISED
|
|
|
|
|
|
def test_min_keep_floor_counts_per_channel_not_globally(
|
|
conn, settings, media_root, channel, materialised
|
|
):
|
|
other = add_channel(conn, "UCzzzzzzzzzzzzzzzzzzzzzz", "Other", "Other")
|
|
settings.set("min_keep_videos", "2")
|
|
materialised([300, 400], chan=channel)
|
|
materialised([300, 400], chan=other)
|
|
|
|
# Two channels, two videos each, floor of two: nothing is eligible. If the
|
|
# floor were global, two of the four would be deleted.
|
|
assert reap.candidates(conn, settings) == []
|
|
|
|
|
|
def test_channel_retention_override_beats_the_global_setting(
|
|
conn, settings, media_root, channel, materialised
|
|
):
|
|
settings.set("min_keep_videos", "0")
|
|
settings.set("retention_days", "365")
|
|
materialised([90])
|
|
assert reap.candidates(conn, settings) == []
|
|
|
|
with conn:
|
|
conn.execute("UPDATE channel SET retention_days = 30 WHERE id = ?",
|
|
(channel["id"],))
|
|
assert len(reap.candidates(conn, settings)) == 1
|
|
|
|
|
|
def test_aged_out_row_is_a_tombstone_the_poller_will_not_revive(
|
|
conn, settings, media_root, channel, materialised
|
|
):
|
|
"""The failure this prevents: sweep deletes, poll re-adds, forever."""
|
|
settings.set("min_keep_videos", "0")
|
|
[video_id] = materialised([99])
|
|
reap.run(conn, settings)
|
|
assert videos.get(conn, video_id)["state"] == videos.AGED_OUT
|
|
|
|
# The video is still in the feed — YouTube has no idea we deleted it.
|
|
entry = {"video_id": video_id, "title": "Video", "published": util.today(),
|
|
"published_at": None}
|
|
outcome = discovery._record(
|
|
conn, channel, entry, videos.SOURCE_UULF, util.today() - timedelta(days=30)
|
|
)
|
|
|
|
assert outcome == "known"
|
|
assert videos.get(conn, video_id)["state"] == videos.AGED_OUT
|
|
|
|
|
|
def test_rescan_revives_skipped_old_but_never_aged_out(
|
|
conn, settings, media_root, channel, materialised
|
|
):
|
|
settings.set("min_keep_videos", "0")
|
|
[gone] = materialised([99])
|
|
reap.run(conn, settings)
|
|
add_video(conn, channel["id"], "skippedold1", upload_date=_old(40),
|
|
state=videos.SKIPPED_OLD)
|
|
|
|
settings.set("retention_days", "365")
|
|
revived = discovery.rescan_channel(conn, settings, channel)
|
|
|
|
assert revived == 1
|
|
assert videos.get(conn, "skippedold1")["state"] == videos.LISTED
|
|
assert videos.get(conn, gone)["state"] == videos.AGED_OUT
|
|
|
|
|
|
def test_empty_season_directory_is_pruned(conn, settings, materialised, media_root):
|
|
settings.set("min_keep_videos", "0")
|
|
[video_id] = materialised([99])
|
|
season_dir = (media_root / videos.get(conn, video_id)["rel_path"]).parent
|
|
assert season_dir.is_dir()
|
|
|
|
reap.run(conn, settings)
|
|
|
|
assert not season_dir.exists()
|
|
# The channel directory survives: it still holds tvshow.nfo and artwork, and
|
|
# an active subscription should not vanish from Jellyfin between uploads.
|
|
assert season_dir.parent.is_dir()
|
|
|
|
|
|
def test_prune_never_climbs_past_the_media_root(media_root):
|
|
nested = media_root / "Chan" / "Season 2026"
|
|
nested.mkdir(parents=True)
|
|
removed = util.prune_empty_dirs(nested, media_root)
|
|
assert removed == 2
|
|
assert media_root.is_dir()
|
|
|
|
|
|
def test_row_without_rel_path_still_gets_a_tombstone(conn, settings, channel):
|
|
"""A video whose files vanished underneath us must not be retried forever."""
|
|
settings.set("min_keep_videos", "0")
|
|
add_video(conn, channel["id"], "orphan0001", upload_date=_old(99),
|
|
state=videos.MATERIALISED)
|
|
row = videos.get(conn, "orphan0001")
|
|
assert row["rel_path"] is None
|
|
|
|
assert reap.delete_video(conn, row) is False
|
|
assert videos.get(conn, "orphan0001")["state"] == videos.AGED_OUT
|