youtube retention: auto-clean old channel episodes (per-channel keep window)
Some checks failed
Compile the app and run tests / sanity-check (push) Has been cancelled

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.
This commit is contained in:
BoulderBadgeDad 2026-06-26 17:24:59 -07:00
parent 151bb7b542
commit 8eadadc1ae
11 changed files with 397 additions and 7 deletions

View file

@ -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})

View file

@ -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 36 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": [

View file

@ -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).

View file

@ -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}

View file

@ -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',
},
]

60
core/video/retention.py Normal file
View file

@ -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_<n>`` keep the newest N episodes by upload date
``days_<n>`` 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

View file

@ -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:

View file

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

View file

@ -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',

View file

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

View file

@ -214,6 +214,9 @@
return '<option value="' + v + '"' + (v === sel ? ' selected' : '') + '>' + v + '</option>';
}).join('');
}
function _keepOpt(v, label, sel) {
return '<option value="' + v + '"' + ((sel || 'all') === v ? ' selected' : '') + '>' + label + '</option>';
}
function _csetForm(title, s, q, dq, kind) {
var on = !!s.quality; // a stored override → start enabled
@ -235,7 +238,17 @@
'<label class="vyt-cset-ck"><input type="checkbox" data-cset-fps' + (b.prefer_60fps !== false ? ' checked' : '') + '> Prefer 60fps</label>' +
'<label class="vyt-cset-ck"><input type="checkbox" data-cset-hdr' + (b.allow_hdr ? ' checked' : '') + '> Allow HDR</label>' +
'</div>' +
'<div class="vyt-cset-hint">Off = use the global YouTube quality from Settings.</div>';
'<div class="vyt-cset-hint">Off = use the global YouTube quality from Settings.</div>' +
// retention — channels only (playlists mirror the whole thing)
(kind === 'playlist' ? '' :
'<label class="vyt-cset-lbl">Keep</label>' +
'<select class="vyt-cset-in" data-cset-keep>' +
_keepOpt('all', 'Everything', s.retention) +
_keepOpt('count_30', 'Last 30 episodes', s.retention) +
_keepOpt('days_90', 'Last 3 months', s.retention) +
_keepOpt('days_180', 'Last 6 months', s.retention) +
'</select>' +
'<div class="vyt-cset-hint">The <strong>Auto-Clean Old YouTube Episodes</strong> automation deletes downloaded episodes outside this window (video + sidecars). The history record is kept, so theyre never re-downloaded.</div>');
}
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 = {