soulsync/core/video/retention.py
BoulderBadgeDad 8eadadc1ae
Some checks failed
Compile the app and run tests / sanity-check (push) Has been cancelled
youtube retention: auto-clean old channel episodes (per-channel keep window)
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.
2026-06-26 17:24:59 -07:00

60 lines
2.5 KiB
Python

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