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.
130 lines
5.6 KiB
Python
130 lines
5.6 KiB
Python
"""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}
|