"""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