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
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:
parent
151bb7b542
commit
8eadadc1ae
11 changed files with 397 additions and 7 deletions
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
130
core/automation/handlers/video_clean_youtube.py
Normal file
130
core/automation/handlers/video_clean_youtube.py
Normal 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}
|
||||
|
|
@ -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
60
core/video/retention.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
113
tests/test_video_retention.py
Normal file
113
tests/test_video_retention.py
Normal 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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 they’re 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 = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue