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.
60 lines
2.5 KiB
Python
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
|