From 8eadadc1ae712d397de086274dbcab2f591f4139 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 26 Jun 2026 17:24:59 -0700 Subject: [PATCH] youtube retention: auto-clean old channel episodes (per-channel keep window) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new opt-in feature — default keeps everything, so nothing changes unless a channel sets it. - history got smarter: youtube rows now carry channel_id + published_at (mined from search_ctx) + a pruned_at marker. new queries: youtube_channels_with_downloads, youtube_channel_episodes, mark_download_pruned. - core/video/retention.py (pure): parse_retention + episodes_to_prune — age by UPLOAD date (published_at, filename fallback); 'count_N' keeps newest N, 'days_N' keeps last N days; undated episodes never pruned. - handler auto_video_clean_youtube_episodes: for each channel with a policy, delete the out-of-window video + its -thumb/.nfo sidecars (only the exact recorded dest_path, never walks folders), then mark the history row pruned — KEPT so the scan never re-downloads it. - 'Keep' dropdown in each channel's cog modal (Everything / Last 30 episodes / Last 3 / 6 months); playlists excluded. seeded daily automation + register/block/label/icon/sort. pure math + handler (i/o injected) + the prune-keeps-dedup DB contract all tested. --- api/video/youtube.py | 3 + core/automation/blocks.py | 2 + core/automation/handlers/registration.py | 7 + .../handlers/video_clean_youtube.py | 130 ++++++++++++++++++ core/automation_engine.py | 9 ++ core/video/retention.py | 60 ++++++++ database/video_database.py | 58 +++++++- tests/test_video_retention.py | 113 +++++++++++++++ webui/static/stats-automations.js | 3 +- webui/static/video/video-automations.js | 1 + webui/static/video/video-youtube.js | 18 ++- 11 files changed, 397 insertions(+), 7 deletions(-) create mode 100644 core/automation/handlers/video_clean_youtube.py create mode 100644 core/video/retention.py create mode 100644 tests/test_video_retention.py diff --git a/api/video/youtube.py b/api/video/youtube.py index 3d26d796..16bd3ef7 100644 --- a/api/video/youtube.py +++ b/api/video/youtube.py @@ -212,6 +212,9 @@ def register_routes(bp): out["custom_name"] = name if body.get("quality"): # falsy/absent → no override (use the global default) out["quality"] = normalize_quality(body.get("quality")) + keep = str(body.get("retention") or "").strip() + if keep and keep != "all": # blank/'all' → no policy (keep everything) + out["retention"] = keep db.set_channel_settings(channel_id, out) return jsonify({"success": True, "settings": out}) diff --git a/core/automation/blocks.py b/core/automation/blocks.py index 600dd3a1..62ce6169 100644 --- a/core/automation/blocks.py +++ b/core/automation/blocks.py @@ -264,6 +264,8 @@ ACTIONS: list[dict] = [ "description": "Full reconcile of the Movie library: re-read every movie from the server and drop ones it no longer has (a read, NOT a Plex disk-scan; never touches TV)", "available": True}, {"type": "video_refresh_airing_schedules", "label": "Refresh Airing TV Schedules", "icon": "calendar", "scope": "video", "description": "Re-pull the latest TMDB episode schedules (air dates, stills) for the still-airing shows on your watchlist, so the calendar the airing automation reads is current — newly-announced or rescheduled episodes get picked up instead of being missed. Skips ended/canceled shows. Pair with a daily Schedule a couple hours before the airing run.", "available": True}, + {"type": "video_clean_youtube_episodes", "label": "Clean Old YouTube Episodes", "icon": "trash", "scope": "video", + "description": "Delete downloaded YouTube channel episodes that fall outside each channel's keep window (set per channel via its cog → Keep: e.g. last 30 episodes / last 3–6 months). Removes the video + its sidecars but keeps the history record so it's never re-downloaded. No-op for channels left on 'keep everything' (the default). Playlists are excluded. Pair with a daily Schedule.", "available": True}, {"type": "video_add_airing_episodes", "label": "Wishlist Today's Airings", "icon": "calendar", "scope": "video", "description": "Sonarr-style: add every episode airing today (for shows you follow) to the wishlist, skipping ones you already own. Also tidies the watchlist by dropping shows that have ended/been canceled.", "available": True, "config_fields": [ diff --git a/core/automation/handlers/registration.py b/core/automation/handlers/registration.py index cc4641ba..183325df 100644 --- a/core/automation/handlers/registration.py +++ b/core/automation/handlers/registration.py @@ -38,6 +38,7 @@ from core.automation.handlers.run_script import auto_run_script from core.automation.handlers.search_and_download import auto_search_and_download from core.automation.handlers.video_auto_wishlist_airing import auto_video_add_airing_episodes from core.automation.handlers.video_refresh_airing_schedules import auto_video_refresh_airing_schedules +from core.automation.handlers.video_clean_youtube import auto_video_clean_youtube_episodes from core.automation.handlers.video_scan_watchlist_people import auto_video_scan_watchlist_people from core.automation.handlers.video_scan_watchlist_channels import auto_video_scan_watchlist_channels from core.automation.handlers.video_process_youtube_wishlist import auto_video_process_youtube_wishlist @@ -246,6 +247,12 @@ def register_all(deps: AutomationDeps) -> None: 'video_refresh_airing_schedules', lambda config: auto_video_refresh_airing_schedules(config, deps), ) + # YouTube retention: delete channel episodes outside each channel's keep window (opt-in + # per channel; default keeps everything). The history row stays so it's not re-downloaded. + engine.register_action_handler( + 'video_clean_youtube_episodes', + lambda config: auto_video_clean_youtube_episodes(config, deps), + ) # ── Watchlist → Wishlist pipeline ───────────────────────────────────────── # Stage 1 — SCANS that fill the wishlist from what you follow. # People: wishlist every un-owned movie followed actors/directors made (catalog + upcoming). diff --git a/core/automation/handlers/video_clean_youtube.py b/core/automation/handlers/video_clean_youtube.py new file mode 100644 index 00000000..f2d95393 --- /dev/null +++ b/core/automation/handlers/video_clean_youtube.py @@ -0,0 +1,130 @@ +"""Automation handler: ``video_clean_youtube_episodes`` action. + +YouTube retention: for each followed channel that has a per-channel retention policy (set in +the channel's cog modal — default 'keep everything'), delete the episodes that fall outside +the keep window (video file + its ``-thumb``/``.nfo`` sidecars). The history row is KEPT and +flagged pruned, so the scan's download-dedup still excludes it — a cleaned episode is never +re-downloaded. Playlists are mirror-the-whole-thing, so they're out of scope (channels only). + +Runs daily. Pure — channel list, per-channel policy, episode list, file deletion + the prune +mark are all injected seams; tests drive it with fakes and never touch a disk or DB. +""" + +from __future__ import annotations + +import logging +import os +from datetime import date +from typing import Any, Callable, Dict, List, Optional + +from core.automation.deps import AutomationDeps +from core.video.retention import episodes_to_prune, parse_retention + +logger = logging.getLogger(__name__) + + +def _default_fetch_channels() -> List[Any]: + from api.video import get_video_db + return get_video_db().youtube_channels_with_downloads() + + +def _default_channel_retention(channel_id: Any) -> Any: + from api.video import get_video_db + return (get_video_db().get_channel_settings(channel_id) or {}).get("retention") + + +def _default_fetch_episodes(channel_id: Any) -> List[Dict[str, Any]]: + from api.video import get_video_db + return get_video_db().youtube_channel_episodes(channel_id) + + +def _default_mark_pruned(history_id: Any, when: str) -> bool: + from api.video import get_video_db + return get_video_db().mark_download_pruned(history_id, when) + + +def _silent_remove(path: str) -> None: + try: + os.remove(path) + except OSError: + pass + + +def _default_delete_files(ep: Dict[str, Any]): + """Delete the episode's video + its sidecars. Returns (ok, freed_bytes). Only ever touches + the exact recorded ``dest_path`` and its same-stem sidecars — never walks a folder. A file + already gone counts as success (mark it pruned); a real delete error does NOT (so it retries).""" + path = ep.get("dest_path") + if not path: + return False, 0 + size = 0 + try: + if os.path.exists(path): + size = os.path.getsize(path) + os.remove(path) + except OSError: + logger.exception("retention: could not delete %s", path) + return False, 0 + stem = os.path.splitext(path)[0] + for sc in (stem + ".nfo", stem + "-thumb.jpg", stem + "-thumb.jpeg", + stem + "-thumb.png", stem + "-thumb.webp"): + if os.path.exists(sc): + _silent_remove(sc) + return True, size + + +def _fmt_gb(b: int) -> str: + gb = (b or 0) / (1024 ** 3) + return ("%.1f GB" % gb) if gb >= 0.1 else "%d MB" % round((b or 0) / (1024 ** 2)) + + +def auto_video_clean_youtube_episodes( + config: Dict[str, Any], + deps: AutomationDeps, + *, + fetch_channels: Optional[Callable[[], List[Any]]] = None, + channel_retention: Optional[Callable[[Any], Any]] = None, + fetch_episodes: Optional[Callable[[Any], List[Dict[str, Any]]]] = None, + delete_files: Optional[Callable[[Dict[str, Any]], Any]] = None, + mark_pruned: Optional[Callable[[Any, str], Any]] = None, + today_fn: Optional[Callable[[], str]] = None, +) -> Dict[str, Any]: + """Delete out-of-window episodes for channels with a retention policy. Returns + ``{'status': 'completed', 'deleted': int, 'channels': int, 'freed_bytes': int, ...}``.""" + fetch_channels = fetch_channels or _default_fetch_channels + channel_retention = channel_retention or _default_channel_retention + fetch_episodes = fetch_episodes or _default_fetch_episodes + delete_files = delete_files or _default_delete_files + mark_pruned = mark_pruned or _default_mark_pruned + today_fn = today_fn or (lambda: date.today().isoformat()) + automation_id = config.get("_automation_id") + try: + today = today_fn() + deps.update_progress(automation_id, phase="Checking retention…", progress=10, + log_line="Looking for channels with a retention policy", log_type="info") + deleted = freed = checked = 0 + for cid in (fetch_channels() or []): + policy = channel_retention(cid) + if not parse_retention(policy): # 'keep everything' / unset → skip + continue + checked += 1 + prune = episodes_to_prune(fetch_episodes(cid) or [], policy, today=today) + for ep in prune: + ok, size = delete_files(ep) + if not ok: + continue + mark_pruned(ep.get("id"), today) # keep the row → scan won't re-download it + deleted += 1 + freed += size or 0 + deps.update_progress(automation_id, log_type="info", + log_line="Removed '%s'" % (ep.get("title") or ep.get("media_id") or "episode")) + + msg = ("Removed %d old episode(s) · freed %s" % (deleted, _fmt_gb(freed))) if deleted \ + else "Nothing to clean — every channel within its retention window" + deps.update_progress(automation_id, status="finished", progress=100, phase="Complete", + log_line=msg, log_type="success") + return {"status": "completed", "deleted": deleted, "channels": checked, + "freed_bytes": freed, "_manages_own_progress": True} + except Exception as e: # noqa: BLE001 + deps.update_progress(automation_id, status="error", phase="Error", log_line=str(e), log_type="error") + return {"status": "error", "error": str(e), "_manages_own_progress": True} diff --git a/core/automation_engine.py b/core/automation_engine.py index 30cfdf3e..bb8a070f 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -316,6 +316,15 @@ SYSTEM_AUTOMATIONS = [ 'initial_delay': 1500, 'owned_by': 'video', }, + # YouTube retention: delete channel episodes outside each channel's keep window. No-op + # unless a channel opts in (cog modal → Keep); default keeps everything, so safe to seed. + { + 'name': 'Auto-Clean Old YouTube Episodes', + 'trigger_type': 'daily_time', + 'trigger_config': {'time': '04:00'}, + 'action_type': 'video_clean_youtube_episodes', + 'owned_by': 'video', + }, ] diff --git a/core/video/retention.py b/core/video/retention.py new file mode 100644 index 00000000..482a9a96 --- /dev/null +++ b/core/video/retention.py @@ -0,0 +1,60 @@ +"""Pure retention math for the YouTube channel auto-clean. + +A channel's retention setting is a small string the cog modal stores: + ``all`` — keep everything (default; nothing is ever deleted) + ``count_`` — keep the newest N episodes by upload date + ``days_`` — keep episodes uploaded within the last N days + +Episodes are aged by their UPLOAD date (``published_at``), falling back to the date in the +filename (the youtube template embeds it) so downloads from before the column existed still +work. An episode with no derivable upload date is NEVER pruned (safe). All file I/O lives in +the handler; this module just decides which episodes fall outside the keep window. +""" + +from __future__ import annotations + +import re +from datetime import date, timedelta +from typing import Any, Dict, List, Optional, Tuple + +_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})") + + +def parse_retention(value: Any) -> Optional[Tuple[str, int]]: + """``'count_30'`` → ``('count', 30)``; ``'all'`` / blank / junk → None (keep everything).""" + if not value or value == "all": + return None + try: + mode, raw = str(value).split("_", 1) + n = int(raw) + except (ValueError, AttributeError): + return None + return (mode, n) if (mode in ("count", "days") and n > 0) else None + + +def episode_date(ep: Dict[str, Any]) -> str: + """The episode's upload date (``YYYY-MM-DD``): ``published_at`` if stored, else parsed + from the filename. ``''`` when neither yields one.""" + p = str(ep.get("published_at") or "")[:10] + if len(p) == 10 and _DATE_RE.fullmatch(p): + return p + m = _DATE_RE.search(str(ep.get("filename") or "")) + return m.group(1) if m else "" + + +def episodes_to_prune(episodes: List[Dict[str, Any]], retention: Any, *, today: str) -> List[Dict[str, Any]]: + """The episodes to DELETE under ``retention`` (newest upload kept first). Pure — episodes + with no derivable upload date are kept. ``today`` is an ISO date for the days-based cutoff.""" + parsed = parse_retention(retention) + if not parsed: + return [] + mode, n = parsed + dated = [(episode_date(e), e) for e in episodes] + dated = sorted([(d, e) for d, e in dated if d], key=lambda t: t[0], reverse=True) + if mode == "count": + return [e for _, e in dated[n:]] # everything beyond the newest n + try: + cutoff = (date.fromisoformat(today) - timedelta(days=n)).isoformat() + except (ValueError, TypeError): + return [] + return [e for d, e in dated if d < cutoff] # uploaded before the cutoff diff --git a/database/video_database.py b/database/video_database.py index 4e96613c..0405b5b6 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -178,6 +178,11 @@ _COLUMN_MIGRATIONS = [ # per-video duration + approximate view count on the remembered catalog ("youtube_channel_videos", "duration", "TEXT"), ("youtube_channel_videos", "view_count", "INTEGER"), + # YouTube retention: owning channel + upload date (group + age episodes) and a prune + # marker — retention deletes the FILE but keeps the row (so the scan won't re-download it). + ("video_download_history", "channel_id", "TEXT"), + ("video_download_history", "published_at", "TEXT"), + ("video_download_history", "pruned_at", "TEXT"), # fanart.tv artwork backfill (gap-fill only; logo/backdrop/poster live already) ("movies", "clearart_url", "TEXT"), ("movies", "banner_url", "TEXT"), ("movies", "fanart_status", "TEXT"), ("movies", "fanart_attempted", "TEXT"), @@ -1486,6 +1491,48 @@ class VideoDatabase: finally: conn.close() + # ── YouTube retention (auto-clean old channel episodes) ────────────────────── + def youtube_channels_with_downloads(self) -> list: + """Channel ids that have at least one still-on-disk (not pruned) downloaded episode — + the set the retention automation iterates.""" + conn = self._get_connection() + try: + return [r["channel_id"] for r in conn.execute( + "SELECT DISTINCT channel_id FROM video_download_history " + "WHERE source='youtube' AND outcome='completed' AND channel_id IS NOT NULL " + "AND pruned_at IS NULL")] + finally: + conn.close() + + def youtube_channel_episodes(self, channel_id) -> list: + """A channel's downloaded, still-on-disk episodes (newest upload first) for retention — + each carries the history id, file path, upload date + filename (date fallback).""" + if not channel_id: + return [] + conn = self._get_connection() + try: + return [dict(r) for r in conn.execute( + "SELECT id, media_id, title, dest_path, filename, published_at, completed_at, size_bytes " + "FROM video_download_history WHERE source='youtube' AND outcome='completed' " + "AND channel_id=? AND pruned_at IS NULL ORDER BY published_at DESC, completed_at DESC", + (str(channel_id),))] + finally: + conn.close() + + def mark_download_pruned(self, history_id, when) -> bool: + """Flag a history row as retention-pruned (file deleted) — kept so the scan's dedup + still excludes it (no re-download). Returns True if a row was updated.""" + conn = self._get_connection() + try: + cur = conn.execute("UPDATE video_download_history SET pruned_at=? " + "WHERE id=? AND pruned_at IS NULL", (when, int(history_id))) + conn.commit() + return cur.rowcount > 0 + except (sqlite3.Error, TypeError, ValueError): + return False + finally: + conn.close() + def media_tmdb_id(self, kind: str, media_id) -> tuple: """(tmdb_id, imdb_id) for a library movie/show row — used to resolve sidecar / subtitle metadata for an owned re-grab (whose media_id is the library id, not a @@ -1570,13 +1617,14 @@ class VideoDatabase: "failed": "failed", "cancelled": "cancelled"}.get(status, status) kind = row.get("kind") or "movie" media_type = "show" if kind == "show" else ("movie" if kind == "movie" else kind) - # season/episode live in the retry search_ctx JSON for episode grabs. - sn = en = None + # season/episode + (youtube) channel/upload-date live in the search_ctx JSON. + sn = en = channel_id = published_at = None ctx = row.get("search_ctx") if ctx: try: ctx = json.loads(ctx) if isinstance(ctx, str) else (ctx or {}) sn, en = ctx.get("season"), ctx.get("episode") + channel_id, published_at = ctx.get("channel_id"), ctx.get("published_at") except (ValueError, TypeError): pass rel, fn = row.get("release_title"), row.get("filename") @@ -1587,15 +1635,15 @@ class VideoDatabase: (download_id, kind, media_type, title, year, season_number, episode_number, release_title, source, username, filename, dest_path, size_bytes, quality_label, resolution, video_codec, media_id, media_source, poster_url, - outcome, error, grabbed_at, completed_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + outcome, error, grabbed_at, completed_at, channel_id, published_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (row.get("id"), kind, media_type, row.get("title"), row.get("year"), sn, en, rel, row.get("source"), row.get("username"), fn, row.get("dest_path"), int(row.get("size_bytes") or 0), row.get("quality_label"), self._parse_resolution(rel, fn, row.get("quality_label")), self._codec(rel, fn), row.get("media_id"), row.get("media_source"), row.get("poster_url"), outcome, row.get("error"), row.get("created_at"), - row.get("completed_at"))) + row.get("completed_at"), channel_id, published_at)) conn.commit() return cur.lastrowid or 0 except Exception: diff --git a/tests/test_video_retention.py b/tests/test_video_retention.py new file mode 100644 index 00000000..beb45971 --- /dev/null +++ b/tests/test_video_retention.py @@ -0,0 +1,113 @@ +"""YouTube channel retention: delete episodes outside a channel's keep window, keep the +history row (so the scan never re-downloads them). Pure retention math + the handler with +all I/O injected + the DB layer (capture channel/upload-date, the prune-but-keep contract).""" + +from __future__ import annotations + +import json + +import pytest + +from core.automation.handlers.video_clean_youtube import auto_video_clean_youtube_episodes +from core.video.retention import episode_date, episodes_to_prune, parse_retention + + +# ── pure retention math ───────────────────────────────────────────────────────── +def test_parse_retention(): + assert parse_retention("all") is None and parse_retention("") is None + assert parse_retention("count_30") == ("count", 30) + assert parse_retention("days_90") == ("days", 90) + assert parse_retention("count_0") is None and parse_retention("junk") is None + + +def test_episode_date_prefers_published_then_filename(): + assert episode_date({"published_at": "2026-06-22"}) == "2026-06-22" + assert episode_date({"published_at": "2026-06-22T10:00:00Z"}) == "2026-06-22" + assert episode_date({"filename": "Chan - 2025-01-15 - Title.mp4"}) == "2025-01-15" # fallback + assert episode_date({"published_at": None, "filename": "no date.mp4"}) == "" + + +def test_episodes_to_prune_by_count_keeps_newest(): + eps = [{"id": i, "published_at": "2026-06-%02d" % (i + 1)} for i in range(5)] # 01..05 + prune = episodes_to_prune(eps, "count_3", today="2026-06-30") + assert sorted(e["id"] for e in prune) == [0, 1] # oldest two go + + +def test_episodes_to_prune_by_days_uses_upload_date(): + eps = [{"id": "old", "published_at": "2026-01-01"}, {"id": "new", "published_at": "2026-06-20"}] + assert [e["id"] for e in episodes_to_prune(eps, "days_90", today="2026-06-30")] == ["old"] + + +def test_undated_and_keep_all_never_prune(): + assert episodes_to_prune([{"id": 1}], "count_1", today="2026-06-30") == [] # no date → kept + assert episodes_to_prune([{"id": 1, "published_at": "2000-01-01"}], "all", today="2026-06-30") == [] + + +# ── handler (I/O injected) ────────────────────────────────────────────────────── +class _Deps: + def __init__(self): + self.progress = [] + + def update_progress(self, automation_id, **kw): + self.progress.append(kw) + + +def _eps(): + return [{"id": 1, "published_at": "2026-06-01", "title": "old"}, + {"id": 2, "published_at": "2026-06-20", "title": "new"}] + + +def test_handler_deletes_out_of_window_marks_pruned_skips_keep_all(): + deleted, pruned = [], [] + res = auto_video_clean_youtube_episodes( + {"_automation_id": "a"}, _Deps(), + fetch_channels=lambda: ["C1", "C2"], + channel_retention=lambda c: "count_1" if c == "C1" else "all", # C2 keep-all → skipped + fetch_episodes=lambda c: _eps(), + delete_files=lambda ep: (deleted.append(ep["id"]) or True, 1000), + mark_pruned=lambda hid, when: pruned.append((hid, when)) or True, + today_fn=lambda: "2026-06-30") + assert res["status"] == "completed" and res["deleted"] == 1 and res["channels"] == 1 + assert deleted == [1] and pruned == [(1, "2026-06-30")] and res["freed_bytes"] == 1000 + + +def test_handler_keep_all_everywhere_is_a_noop(): + res = auto_video_clean_youtube_episodes( + {"_automation_id": "a"}, _Deps(), fetch_channels=lambda: ["C1"], + channel_retention=lambda c: "all", fetch_episodes=lambda c: _eps(), + delete_files=lambda e: (True, 0), mark_pruned=lambda h, w: True, today_fn=lambda: "2026-06-30") + assert res["deleted"] == 0 and res["channels"] == 0 + + +def test_handler_delete_failure_is_not_marked_pruned(): + marked = [] + res = auto_video_clean_youtube_episodes( + {"_automation_id": "a"}, _Deps(), fetch_channels=lambda: ["C1"], + channel_retention=lambda c: "count_1", fetch_episodes=lambda c: _eps(), + delete_files=lambda e: (False, 0), # couldn't delete → retry next run + mark_pruned=lambda h, w: marked.append(h), today_fn=lambda: "2026-06-30") + assert res["deleted"] == 0 and marked == [] + + +# ── DB layer ──────────────────────────────────────────────────────────────────── +from database.video_database import VideoDatabase # noqa: E402 + + +@pytest.fixture() +def db(tmp_path): + return VideoDatabase(database_path=str(tmp_path / "video_library.db")) + + +def test_history_captures_channel_and_upload_date_and_prune_keeps_dedup(db): + db.record_download_history({ + "id": 1, "kind": "youtube", "source": "youtube", "media_id": "v1", + "status": "completed", "dest_path": "/yt/Chan/Season 2026/Chan - 2026-06-22 - T.mp4", + "search_ctx": json.dumps({"channel_id": "UC1", "published_at": "2026-06-22"})}) + eps = db.youtube_channel_episodes("UC1") + assert len(eps) == 1 and eps[0]["published_at"] == "2026-06-22" and eps[0]["media_id"] == "v1" + assert db.youtube_channels_with_downloads() == ["UC1"] + + assert db.mark_download_pruned(eps[0]["id"], "2026-06-30") is True + assert db.youtube_channel_episodes("UC1") == [] # gone from the retention view + assert db.youtube_channels_with_downloads() == [] + assert "v1" in db.downloaded_youtube_video_ids() # …but still counts as downloaded → no re-grab diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 94825836..4578f562 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -1780,7 +1780,7 @@ const _autoIcons = { video_scan_watchlist_playlists: '\uD83C\uDFB5', video_process_movie_wishlist: '\uD83C\uDFAC', video_process_episode_wishlist: '\uD83D\uDCFA', video_process_youtube_wishlist: '\u2B07\uFE0F', - video_refresh_airing_schedules: '\uD83D\uDDD3\uFE0F', + video_refresh_airing_schedules: '\uD83D\uDDD3\uFE0F', video_clean_youtube_episodes: '\uD83E\uDDF9', video_clean_search_history: '\uD83D\uDDD1\uFE0F', video_clean_completed_downloads: '\u2705', video_full_cleanup: '\uD83E\uDDF9', video_backup_database: '\uD83D\uDCBE', }; @@ -3385,6 +3385,7 @@ function _autoFormatAction(type) { video_process_episode_wishlist: 'Process Episode Wishlist', video_process_youtube_wishlist: 'Process YouTube Wishlist', video_refresh_airing_schedules: 'Refresh Airing Schedules', + video_clean_youtube_episodes: 'Clean Old YouTube Episodes', video_clean_search_history: 'Clean Search History', video_clean_completed_downloads: 'Clean Completed Downloads', video_full_cleanup: 'Full Cleanup', video_backup_database: 'Backup Database', diff --git a/webui/static/video/video-automations.js b/webui/static/video/video-automations.js index 6f6b2ddb..f22a492b 100644 --- a/webui/static/video/video-automations.js +++ b/webui/static/video/video-automations.js @@ -35,6 +35,7 @@ 'video_scan_watchlist_playlists', 'video_refresh_airing_schedules', 'video_add_airing_episodes', // Stage 2 — processors that DRAIN it (download) 'video_process_movie_wishlist', 'video_process_episode_wishlist', 'video_process_youtube_wishlist', + 'video_clean_youtube_episodes', // Library scan / sync 'video_scan_server', 'video_update_database', 'video_update_database_hourly', 'video_deep_scan_tv', 'video_deep_scan_movies', // Maintenance diff --git a/webui/static/video/video-youtube.js b/webui/static/video/video-youtube.js index 3f0127a3..eef10f63 100644 --- a/webui/static/video/video-youtube.js +++ b/webui/static/video/video-youtube.js @@ -214,6 +214,9 @@ return ''; }).join(''); } + function _keepOpt(v, label, sel) { + return ''; + } function _csetForm(title, s, q, dq, kind) { var on = !!s.quality; // a stored override → start enabled @@ -235,7 +238,17 @@ '' + '' + '' + - '
Off = use the global YouTube quality from Settings.
'; + '
Off = use the global YouTube quality from Settings.
' + + // retention — channels only (playlists mirror the whole thing) + (kind === 'playlist' ? '' : + '' + + '' + + '
The Auto-Clean Old YouTube Episodes automation deletes downloaded episodes outside this window (video + sidecars). The history record is kept, so they’re never re-downloaded.
'); } function openChannelSettings(channelId, title, kind) { @@ -270,6 +283,9 @@ saveBtn.addEventListener('click', function () { var nameEl = body.querySelector('[data-cset-name]'); var payload = { custom_name: (nameEl ? nameEl.value : '').trim() }; + var keepEl = body.querySelector('[data-cset-keep]'); + // '' for 'Everything' → dropped server-side → keep-all (clears any prior policy) + payload.retention = (keepEl && keepEl.value !== 'all') ? keepEl.value : ''; var qon = body.querySelector('[data-cset-qon]'); if (qon && qon.checked) { payload.quality = {