Compare commits

...

10 commits

Author SHA1 Message Date
BoulderBadgeDad
8eadadc1ae 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.
2026-06-26 17:24:59 -07:00
BoulderBadgeDad
151bb7b542 video: hourly 'Update Video Database' safety net (the second-trigger pattern)
dual triggers on one automation aren't a thing (one trigger per row, same as music), so
this adds a second automation sharing the handler — like Auto-Deep Scan TV/Movie already
do. 'Auto-Update Video Database (Hourly)' runs the same cheap incremental server-read on
an hourly schedule, so MANUAL library additions (which Plex auto-scans) appear within the
hour instead of waiting for the weekly deep scan; the existing after-scan one still gives
instant updates for SoulSync-initiated scans.

new action_type video_update_database_hourly (seeder keys on action_type) reusing
auto_video_update_database in incremental mode; registered, seeded (schedule 1h, 15m
initial delay), UI block, label/icon, sort order. tested.
2026-06-26 16:39:28 -07:00
BoulderBadgeDad
11c0c62a7e video enrichment: stop OMDb traceback spam from the detail/drawer path too
the earlier latch only covered _backfill_ratings. _fill_tmdb_ratings (hit by
tmdb_full_detail → detail pages, the download drawer's meta endpoint, the airing
automation's status lookups) still re-hit OMDb once per title and dumped a traceback
each time once over quota. now it shares the same _omdb_blocked latch: checks it before
calling, sets it + logs ONE quiet warning on OMDbAuthError. (the bulk OMDb worker already
cools down + auto-resumes, so it was never the spammer.) tested.
2026-06-26 16:28:37 -07:00
BoulderBadgeDad
75f23641b3 youtube downloads: show 'Importing' during the ffmpeg merge, not a stuck 100%
the '100% but still Downloading for a while' phase was yt-dlp merging audio+video
(ffmpeg) INSIDE the download call — status stayed 'downloading' the whole time. now a
postprocessor_hook flips the row to 'importing' the moment post-processing starts, so
the card shows 'Importing' through the merge AND the library move (which already set
it), then completes. matches the movies/TV treatment. hook wiring tested.
2026-06-26 15:37:08 -07:00
BoulderBadgeDad
f6360e90da youtube downloads: write episode sidecars (-thumb.jpg + .nfo), gated by toggles
best-in-class for Plex/Jellyfin, matching ytdl-sub: each youtube episode now gets a
'<name>-thumb.jpg' (episode art) + a '<name>.nfo' (<episodedetails>: title, plot from
the video description, aired date, season=year, episode=MMDD, channel, youtube uniqueid).

- yt-dlp now writes the thumbnail + an info.json next to the staged video; the import
  step renames the thumb, mines the json for the nfo, and cleans both up (so nothing
  litters the download folder when a toggle is off).
- gated by the SAME post-processing toggles as movie/TV (save_artwork / write_nfo),
  which already exist in Settings → Library; youtube just honours them now.
- flipped save_artwork + write_nfo defaults ON (cheap, local). download_subtitles stays
  opt-in (it hits OpenSubtitles — external + rate-limited).
build_episode_nfo + the gated sidecar I/O tested.
2026-06-26 13:57:52 -07:00
BoulderBadgeDad
02a319c350 gitignore: ignore database/*.histbak (the suffix slipped past the backup_* rule) 2026-06-26 13:41:50 -07:00
BoulderBadgeDad
ca1348b2c2 downloads: youtube 'open' goes to the in-app channel page, not youtube.com
the open button now opens the channel inside soulsync (channels-as-shows) via
video-open-detail {kind:'channel', source:'youtube', id}, matching how movie/show
cards open their in-app detail. enqueue_ctx now carries channel_id (new downloads)
and youtube_video_detail returns it too (so older downloads resolve the channel in
the drawer). drawer gets an 'Open channel' button alongside 'Open on YouTube';
falls back to the youtube video link only when the channel id is unknown.
2026-06-26 13:24:41 -07:00
BoulderBadgeDad
3f924812da downloads: youtube card open button → YouTube link (was opening a random show)
the open-show button rendered for youtube cards too; the click ran parseInt on the
youtube video id → ids starting with a digit became a small library id (e.g. 3 →
'3rd Rock from the Sun'), others became NaN → broken link. youtube cards now render a
real YouTube link for that button; movie/show/episode keep the open-detail button.
regression test pins the youtube branch.
2026-06-26 13:19:47 -07:00
BoulderBadgeDad
b916e6a2f3 downloads drawer: episode detail + youtube treatment + availability line
- EPISODE downloads now show the specific episode: still + 'S02E05 · air date' + that
  episode's own title + synopsis (was just the show synopsis). meta endpoint takes
  ?season=&episode= → engine.tmdb_season; show cast/logo stay as context.
- YOUTUBE drawer: big 16:9 thumbnail + channel · duration · views · upload date +
  description. new yt-meta route → db.youtube_video_detail (cached duration/views).
- AVAILABILITY line: the chosen source's free-slot/queue/speed snapshot, stashed in
  search_ctx at grab time (build_download_record) → 'Availability: ✓ free slot · queue 0
  · 2.1 MB/s'.
drawer also reworked into clean youtube / episode / movie-show branches; first paint
shows 'Loading…' instead of flashing 'no synopsis'. tested.
2026-06-26 13:06:44 -07:00
BoulderBadgeDad
c35ec43466 downloads drawer: rich TMDB detail + fix cast photos + stop OMDb quota burn
drawer upgrades (the TMDB call we already make returns all this — just render it):
- FIX cast photos — used c.profile_url; the field is c.photo. now shows headshots.
- title LOGO header (falls back to text) + meta line (year · rating · runtime · network).
- tagline, director/creator, ▶ trailer link, and a 'Watch on' provider-logo row.
- meta endpoint widened to return logo/cast-photo/rating/runtime/crew/trailer/providers.

OMDb spam fix (the 'Request limit reached!' tracebacks):
- the new bulk schedule-refresh called refresh_show_art per show, which did an OMDb
  ratings backfill each → blew the daily quota. refresh_show_art gains with_ratings;
  the automation passes False (it only needs episode schedules).
- _backfill_ratings now latches _omdb_blocked on OMDbAuthError → one quiet warning +
  stops calling OMDb for the rest of the process, instead of a traceback per show.
tested (cast/logo/trailer contract, no-ratings plumbing, the latch-off behavior).
2026-06-26 12:49:11 -07:00
28 changed files with 1018 additions and 70 deletions

1
.gitignore vendored
View file

@ -20,6 +20,7 @@ database/video_library.db
database/video_library.db-shm
database/video_library.db-wal
database/video_library.db.backup_*
database/*.histbak
database/api_call_history.json
storage/image_cache/
logs/*.log

View file

@ -172,21 +172,62 @@ def register_routes(bp):
@bp.route("/downloads/meta/<kind>/<int:tmdb_id>", methods=["GET"])
def video_download_meta(kind, tmdb_id):
"""Lazy TMDB detail (overview + cast + backdrop) for a download's expand drawer,
keyed by the grabbed title's TMDB id. Best-effort — the drawer still shows the
download facts without it."""
"""Lazy TMDB detail for a download's expand drawer (logo, cast w/ photos, trailer,
where-to-watch, rating/runtime/genres), keyed by the grabbed title's TMDB id.
Best-effort the drawer still shows the download facts without it."""
if kind not in ("movie", "show"):
return jsonify({}), 400
try:
from core.video.enrichment.engine import get_video_enrichment_engine
data = get_video_enrichment_engine().tmdb_full_detail(kind, tmdb_id) or {}
return jsonify({k: data.get(k) for k in
("title", "overview", "tagline", "backdrop_url", "poster_url",
"genres", "rating", "cast", "year", "runtime", "status")})
d = get_video_enrichment_engine().tmdb_full_detail(kind, tmdb_id) or {}
if not d:
return jsonify({})
extras = d.get("_extras") or {}
tr = extras.get("trailer") or {}
director = next((c.get("name") for c in (d.get("crew") or [])
if (c.get("job") or "").lower() in ("director", "creator")), None)
# episode-specific detail (still + that episode's own title/overview/air date) when a
# specific episode is downloading — more relevant than the show synopsis.
episode = None
sn, en = request.args.get("season"), request.args.get("episode")
if kind == "show" and sn and en:
try:
season = get_video_enrichment_engine().tmdb_season(tmdb_id, int(sn)) or {}
ep = next((e for e in (season.get("episodes") or [])
if str(e.get("episode_number")) == str(int(en))), None)
if ep:
episode = {"season": int(sn), "episode": int(en), "title": ep.get("title"),
"overview": ep.get("overview"), "air_date": ep.get("air_date"),
"still_url": ep.get("still_url")}
except (ValueError, TypeError):
pass
return jsonify({
"title": d.get("title"), "overview": d.get("overview"), "tagline": d.get("tagline"),
"backdrop_url": d.get("backdrop_url"), "logo": d.get("logo"),
"genres": d.get("genres") or [], "rating": d.get("rating"),
"runtime_minutes": d.get("runtime_minutes"), "year": d.get("year"),
"network": d.get("network"), "studio": d.get("studio"),
"status": d.get("status"), "director": director, "episode": episode,
"cast": [{"name": c.get("name"), "character": c.get("character"), "photo": c.get("photo")}
for c in (d.get("cast") or [])[:10]],
"trailer_url": ("https://www.youtube.com/watch?v=" + tr["key"]) if tr.get("key") else None,
"providers": (extras.get("providers") or [])[:6],
"providers_link": extras.get("providers_link"),
})
except Exception:
logger.exception("download meta failed for %s %s", kind, tmdb_id)
return jsonify({})
@bp.route("/downloads/yt-meta/<video_id>", methods=["GET"])
def video_download_yt_meta(video_id):
"""Cached extra detail for a YouTube download's drawer (duration / views / thumbnail)."""
from . import get_video_db
try:
return jsonify(get_video_db().youtube_video_detail(video_id) or {})
except Exception:
logger.exception("yt meta failed for %s", video_id)
return jsonify({})
@bp.route("/downloads/quality", methods=["GET"])
def video_quality_profile():
from . import get_video_db

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

@ -253,6 +253,8 @@ ACTIONS: list[dict] = [
{"value": "show", "label": "TV only"}],
"default": "all"}
]},
{"type": "video_update_database_hourly", "label": "Update Video Database (Hourly)", "icon": "database", "scope": "video",
"description": "The same incremental server-read on an hourly schedule, so manual library additions (which Plex auto-scans) appear within the hour instead of waiting for the weekly deep scan. Pair with a 1-hour Schedule trigger.", "available": True},
# Per-library deep-scan presets (the system 'Auto-Deep Scan TV/Movie Library' run
# these). Scope + deep mode are baked in by the registration wrapper, so no config
# fields — drag one in and it just deep-scans that library.
@ -262,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
@ -228,6 +229,13 @@ def register_all(deps: AutomationDeps) -> None:
'video_update_database',
lambda config: auto_video_update_database(config, deps),
)
# Same incremental update, but on an hourly SCHEDULE (not just after a SoulSync scan) —
# so manual library additions (which Plex auto-scans) show up within the hour instead of
# waiting for the weekly deep scan. A distinct action_type because the seeder keys on it.
engine.register_action_handler(
'video_update_database_hourly',
lambda config: auto_video_update_database({'mode': 'incremental', **config}, deps),
)
# Sonarr-style: wishlist every episode airing today (for followed shows).
engine.register_action_handler(
'video_add_airing_episodes',
@ -239,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

@ -83,6 +83,11 @@ def build_download_record(item: Dict[str, Any], best: Dict[str, Any], candidates
grab, so the monitor finishes it the same way (other accepted hits become the retry
pool)."""
ctx = search_context(item, media_type)
# stash the chosen source's peer stats so the drawer can show its availability snapshot
# (free slot / queue depth / speed at grab time). Retry ignores the extra key.
peer = {k: best.get(k) for k in ("slots", "queue", "speed", "availability") if best.get(k) is not None}
if peer:
ctx = {**ctx, "peer": peer}
rest = [c for c in (candidates or []) if c.get("filename") != best.get("filename")]
media_id = str(item.get("tmdb_id") if media_type == "movie" else item.get("show_tmdb_id"))
return {

View file

@ -50,6 +50,7 @@ def enqueue_ctx(video: Dict[str, Any], channel_settings: Dict[str, Any]) -> Dict
cs = channel_settings if isinstance(channel_settings, dict) else {}
ctx = {
"channel": cs.get("custom_name") or video.get("channel_title"),
"channel_id": video.get("channel_id"), # so the drawer can open the in-app channel page
"video_title": video.get("video_title"),
"published_at": video.get("published_at"),
}

View file

@ -32,9 +32,11 @@ def _default_fetch_shows() -> List[Dict[str, Any]]:
def _default_refresh_show(library_id: Any) -> Dict[str, Any]:
"""Re-pull a library show's TMDB season/episode schedule (the lazy on-view backfill,
invoked deliberately). Re-matches + cascades episodes, so air dates/stills refresh."""
invoked deliberately). Re-matches + cascades episodes, so air dates/stills refresh.
``with_ratings=False`` we only need schedules, and the per-show OMDb ratings call
would burn the daily quota across a whole watchlist."""
from core.video.enrichment.engine import get_video_enrichment_engine
return get_video_enrichment_engine().refresh_show_art(library_id) or {}
return get_video_enrichment_engine().refresh_show_art(library_id, with_ratings=False) or {}
def auto_video_refresh_airing_schedules(

View file

@ -170,6 +170,18 @@ SYSTEM_AUTOMATIONS = [
'action_config': {'mode': 'incremental'},
'owned_by': 'video',
},
# Safety net: re-read the server hourly too, so MANUAL library additions (which Plex
# auto-scans) appear within the hour instead of waiting for the weekly deep scan. Same
# cheap incremental read as the after-scan one.
{
'name': 'Auto-Update Video Database (Hourly)',
'trigger_type': 'schedule',
'trigger_config': {'interval': 1, 'unit': 'hours'},
'action_type': 'video_update_database_hourly',
'action_config': {'mode': 'incremental'},
'initial_delay': 900,
'owned_by': 'video',
},
# Sonarr-style: once a day at 1am (server-local), wishlist every episode airing
# today for the shows you follow (skipping ones already owned) so the day's episodes
# are queued overnight. A fixed wall-clock 'daily_time' (not a rolling 24h interval
@ -304,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',
},
]

View file

@ -12,6 +12,7 @@ import threading
from utils.logging_config import get_logger
from .cache import TTLCache
from .clients import OMDbAuthError
from .worker import VideoEnrichmentWorker
logger = get_logger("video_enrichment.engine")
@ -62,7 +63,10 @@ class VideoEnrichmentEngine:
# for tests that don't build a worker).
w = self.workers.get("omdb")
rc = w.client if w else self.ratings_client
if not rc or not getattr(rc, "enabled", False):
# _omdb_blocked latches once the daily request limit / a bad key is hit — it affects
# EVERY item, so we stop calling OMDb for the rest of the process instead of failing
# (and logging a traceback) once per show.
if not rc or not getattr(rc, "enabled", False) or getattr(self, "_omdb_blocked", False):
return
info = (self.db.movie_match_info(item_id) if kind == "movie"
else self.db.show_match_info(item_id))
@ -81,6 +85,10 @@ class VideoEnrichmentEngine:
ratings = rc.ratings(imdb_id)
if ratings:
self.db.apply_ratings(kind, item_id, ratings)
except OMDbAuthError as e:
# daily limit / bad key — hits every item; latch off + one quiet warning, no spam.
self._omdb_blocked = True
logger.warning("OMDb ratings paused for this run: %s", e)
except Exception:
logger.exception("ratings backfill failed for %s %s", kind, item_id)
@ -142,11 +150,15 @@ class VideoEnrichmentEngine:
", ".join(sorted(self._scan_paused)))
self._scan_paused = set()
def refresh_show_art(self, show_id) -> dict:
def refresh_show_art(self, show_id, *, with_ratings: bool = True) -> dict:
"""On-demand (lazy) backfill of a show's season posters + episode art from
TMDB, used when the detail page is opened and art is missing. Works
regardless of the show's match status (sidesteps 'already matched, never
re-runs'), and caches the result so it's a one-time cost per show."""
re-runs'), and caches the result so it's a one-time cost per show.
``with_ratings=False`` skips the OMDb ratings backfill used by the bulk
'Refresh Airing TV Schedules' automation, which only needs episode schedules and
would otherwise burn the daily OMDb quota one call per show."""
w = self.workers.get("tmdb")
if not w or not w.enabled:
return {"ok": False, "reason": "tmdb_not_configured"}
@ -169,7 +181,8 @@ class VideoEnrichmentEngine:
w._cascade_episodes(show_id, result["id"], nums) # full list: owned + missing
except Exception:
logger.exception("refresh_show_art: episode cascade failed for show %s", show_id)
self._backfill_ratings("show", show_id)
if with_ratings:
self._backfill_ratings("show", show_id)
return {"ok": True}
def refresh_movie_art(self, movie_id) -> dict:
@ -554,13 +567,19 @@ class VideoEnrichmentEngine:
def _fill_tmdb_ratings(self, d) -> None:
imdb_id = d.get("imdb_id")
ow = self.workers.get("omdb")
if not imdb_id or not ow or not getattr(ow.client, "enabled", False):
# share the same daily-limit latch as _backfill_ratings — once OMDb is over quota,
# every detail fetch would otherwise re-hit it and dump a traceback per title.
if (not imdb_id or not ow or not getattr(ow.client, "enabled", False)
or getattr(self, "_omdb_blocked", False)):
return
try:
r = ow.client.ratings(imdb_id) or {}
for k in ("imdb_rating", "rt_rating", "metacritic"):
if r.get(k) is not None:
d[k] = r[k]
except OMDbAuthError as e:
self._omdb_blocked = True
logger.warning("OMDb ratings paused for this run: %s", e)
except Exception:
logger.exception("tmdb_detail ratings failed for %s", imdb_id)

View file

@ -44,9 +44,9 @@ DEFAULTS = {
"replace_existing": True,
"transfer_mode": "copy",
"carry_subtitles": True,
"save_artwork": False,
"write_nfo": False,
"download_subtitles": False,
"save_artwork": True, # nfo + artwork sidecars on by default (cheap, local) — best-in-class
"write_nfo": True,
"download_subtitles": False, # opt-in: fetches from OpenSubtitles (external, rate-limited)
"subtitle_langs": "en",
}

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

@ -84,7 +84,8 @@ def plan_destination(dl: Dict[str, Any], settings: Dict[str, Any], container: st
def ydl_download_opts(profile: Any, dest_dir: str, dest_stem: str,
*, progress_hook: Optional[Callable] = None, cookie_opts: Optional[dict] = None) -> dict:
*, progress_hook: Optional[Callable] = None, postprocess_hook: Optional[Callable] = None,
cookie_opts: Optional[dict] = None) -> dict:
"""The yt-dlp options dict for one download: format selection from the quality profile,
a fixed output path (dir + stem + yt-dlp's own ext), polite defaults. Pure."""
sel = format_selection(profile)
@ -98,11 +99,19 @@ def ydl_download_opts(profile: Any, dest_dir: str, dest_stem: str,
"merge_output_format": sel["merge_output_format"],
"paths": {"home": str(dest_dir or "")},
"outtmpl": dest_stem + ".%(ext)s",
# sidecars: the episode thumbnail (→ '<name>-thumb.jpg' on import) + the metadata
# json we mine for the .nfo (description / duration). ffmpeg (already needed to merge)
# normalises the thumbnail to jpg.
"writethumbnail": True,
"writeinfojson": True,
"postprocessors": [{"key": "FFmpegThumbnailsConvertor", "format": "jpg"}],
}
if cookie_opts:
opts.update(cookie_opts)
if progress_hook:
opts["progress_hooks"] = [progress_hook]
if postprocess_hook:
opts["postprocessor_hooks"] = [postprocess_hook] # fires while ffmpeg merges/converts
return opts
@ -116,7 +125,7 @@ def _stem_and_container(dest: Dict[str, str], container: str) -> tuple:
def download_one(video_id: Any, dest_dir: str, dest_stem: str, profile: Any, container: str,
*, ydl_factory=None, progress_hook=None, cookie_opts=None) -> Dict[str, Any]:
*, ydl_factory=None, progress_hook=None, postprocess_hook=None, cookie_opts=None) -> Dict[str, Any]:
"""Run yt-dlp for ONE video into ``dest_dir/dest_stem.ext``. Returns
``{ok, dest_path|None, error|None}``. The yt-dlp class is injectable for tests."""
vid = str(video_id or "").strip()
@ -125,8 +134,8 @@ def download_one(video_id: Any, dest_dir: str, dest_stem: str, profile: Any, con
factory = ydl_factory or (yt_dlp.YoutubeDL if yt_dlp else None)
if factory is None:
return {"ok": False, "dest_path": None, "error": "yt-dlp unavailable"}
opts = ydl_download_opts(profile, dest_dir, dest_stem,
progress_hook=progress_hook, cookie_opts=cookie_opts)
opts = ydl_download_opts(profile, dest_dir, dest_stem, progress_hook=progress_hook,
postprocess_hook=postprocess_hook, cookie_opts=cookie_opts)
url = vid if vid.startswith("http") else "https://www.youtube.com/watch?v=" + vid
try:
with factory(opts) as ydl:
@ -144,6 +153,84 @@ def _default_move(src: str, dest: str) -> None:
shutil.move(src, dest)
def build_episode_nfo(fields: Dict[str, Any], *, description: Any = None, runtime: Any = None) -> str:
"""A Jellyfin/Kodi/Plex ``<episodedetails>`` sidecar for a YouTube 'episode'. Pure — the
description/runtime come from yt-dlp's info json. season = upload year, episode = MMDD
(Plex 'by date' matches on ``<aired>``; the numbers help Jellyfin/Kodi)."""
from xml.sax.saxutils import escape
date = str(fields.get("published_at") or "")[:10]
year = date[:4]
out = ['<?xml version="1.0" encoding="UTF-8"?>', '<episodedetails>',
' <title>%s</title>' % escape(str(fields.get("title") or fields.get("channel") or "Video"))]
if year.isdigit():
out.append(' <season>%s</season>' % year)
if len(date) == 10 and date[5:7].isdigit() and date[8:10].isdigit():
out.append(' <episode>%d</episode>' % int(date[5:7] + date[8:10]))
if description:
out.append(' <plot>%s</plot>' % escape(str(description)))
if date:
out.append(' <aired>%s</aired>' % escape(date))
if fields.get("channel"):
out.append(' <studio>%s</studio>' % escape(str(fields["channel"])))
if fields.get("youtube_id"):
out.append(' <uniqueid type="youtube" default="true">%s</uniqueid>' % escape(str(fields["youtube_id"])))
try:
if runtime:
out.append(' <runtime>%d</runtime>' % round(float(runtime) / 60))
except (TypeError, ValueError):
pass
out.append('</episodedetails>')
return "\n".join(out) + "\n"
def _silent_remove(path: str) -> None:
try:
os.remove(path)
except OSError:
pass
def _default_sidecars(staged_video: str, final_video: str, fields: Dict[str, Any],
settings: Dict[str, Any]) -> None:
"""Place the YouTube episode's sidecars next to the imported video — gated by the SAME
post-processing toggles as the movie/TV side: ``save_artwork`` ``<name>-thumb.jpg``
(server episode art), ``write_nfo`` ``<name>.nfo`` (metadata). yt-dlp dropped a
thumbnail + ``.info.json`` next to the staged video; we always clean those up (move the
wanted ones into the library, delete the rest), so nothing litters the download folder
when a toggle is off. Best-effort never fails the grab."""
settings = settings if isinstance(settings, dict) else {}
want_thumb, want_nfo = bool(settings.get("save_artwork")), bool(settings.get("write_nfo"))
try:
src_dir, src_stem = os.path.dirname(staged_video), os.path.splitext(os.path.basename(staged_video))[0]
dst_dir, dst_stem = os.path.dirname(final_video), os.path.splitext(os.path.basename(final_video))[0]
# thumbnail: keep as -thumb when wanted, else discard the staged copy
for ext in (".jpg", ".jpeg", ".png", ".webp"):
src_thumb = os.path.join(src_dir, src_stem + ext)
if os.path.exists(src_thumb):
if want_thumb:
os.makedirs(dst_dir or ".", exist_ok=True)
shutil.move(src_thumb, os.path.join(dst_dir, dst_stem + "-thumb" + (".jpg" if ext == ".jpeg" else ext)))
else:
_silent_remove(src_thumb)
break
# info json → mine for the nfo (when wanted), then always drop it
info = {}
info_path = os.path.join(src_dir, src_stem + ".info.json")
if os.path.exists(info_path):
try:
with open(info_path, encoding="utf-8") as f:
info = json.load(f)
except (ValueError, OSError):
info = {}
_silent_remove(info_path)
if want_nfo:
os.makedirs(dst_dir or ".", exist_ok=True)
with open(os.path.join(dst_dir, dst_stem + ".nfo"), "w", encoding="utf-8") as f:
f.write(build_episode_nfo(fields, description=info.get("description"), runtime=info.get("duration")))
except Exception: # noqa: BLE001 - sidecars are a nice-to-have, never fatal to the grab
logger.exception("youtube sidecars failed for %s", final_video)
def process_youtube_download(
dl: Dict[str, Any],
*,
@ -155,7 +242,9 @@ def process_youtube_download(
clear_wishlist: Callable[[Any], Any],
stage_dir: Optional[str] = None,
move: Callable[[str, str], Any] = _default_move,
sidecars: Callable[[str, str, Dict[str, Any], Dict[str, Any]], Any] = _default_sidecars,
progress_hook: Optional[Callable] = None,
postprocess_hook: Optional[Callable] = None,
cookie_opts: Optional[dict] = None,
now: Optional[Callable[[], str]] = None,
) -> Dict[str, Any]:
@ -183,7 +272,7 @@ def process_youtube_download(
update_row(dl.get("id"), status="downloading", progress=0, filename=dest.get("filename"))
res = download(dl.get("media_id"), dl_dir, stem, profile, cont,
progress_hook=progress_hook, cookie_opts=cookie_opts)
progress_hook=progress_hook, postprocess_hook=postprocess_hook, cookie_opts=cookie_opts)
if not res.get("ok"):
err = res.get("error") or "Download failed"
@ -211,6 +300,10 @@ def process_youtube_download(
else:
dest_path = staged_path or final_path
# episode sidecars (-thumb.jpg + .nfo) next to the imported video — gated by the
# save_artwork / write_nfo post-processing toggles (shared with the movie/TV side).
sidecars(staged_path, dest_path, youtube_fields_from_download(dl), settings)
completed = now()
update_row(dl.get("id"), status="completed", progress=100,
dest_path=dest_path, completed_at=completed)
@ -310,6 +403,16 @@ def run_youtube_download(dl_id: Any, db_provider: Callable) -> None:
except Exception: # noqa: BLE001, S110 - a progress glitch must not abort the download
pass
def _postprocess(d):
# yt-dlp finished the bytes and is now merging audio+video / converting (ffmpeg) — flip
# to 'importing' so the card stops sitting on a stuck-looking 100% 'Downloading' until
# it (and the library move that follows) are done. Best-effort.
try:
if d.get("status") in ("started", "processing"):
db.update_video_download(dl_id, status="importing", progress=100)
except Exception: # noqa: BLE001, S110 - a hook glitch must not abort the download
pass
def _archive(row, upd):
try:
db.record_download_history({**row, **upd})
@ -337,7 +440,7 @@ def run_youtube_download(dl_id: Any, db_provider: Callable) -> None:
update_row=db.update_video_download, archive=_archive,
clear_wishlist=lambda vid: db.remove_youtube_from_wishlist("video", vid),
stage_dir=stage_dir,
progress_hook=_progress, cookie_opts=cookie_opts, now=_now)
progress_hook=_progress, postprocess_hook=_postprocess, cookie_opts=cookie_opts, now=_now)
finally:
_active_worker_ids.discard(dl_id) # worker done — no longer protects this row
start_next_queued(db_provider) # one out, one in — drain the queue

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"),
@ -1474,6 +1479,60 @@ class VideoDatabase:
finally:
conn.close()
def youtube_video_detail(self, youtube_id) -> dict | None:
"""Cached metadata for one YouTube video (title / thumbnail / duration / views) — the
extra detail the download drawer shows. None if it was never cached by a channel scan."""
conn = self._get_connection()
try:
r = conn.execute(
"SELECT channel_id, title, thumbnail_url, duration, view_count "
"FROM youtube_channel_videos WHERE youtube_id=? LIMIT 1", (youtube_id,)).fetchone()
return dict(r) if r else None
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
@ -1558,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")
@ -1575,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

@ -120,6 +120,16 @@ def test_every_video_automation_has_a_friendly_label_and_icon():
assert not missing, "video actions missing a label/icon: " + ", ".join(sorted(missing))
def test_hourly_db_update_is_a_scheduled_safety_net():
# second trigger (a schedule) for the same incremental DB read, so manual library adds
# show up within the hour instead of waiting for the weekly deep scan.
import core.automation_engine as ae
row = next((a for a in ae.SYSTEM_AUTOMATIONS if a.get("action_type") == "video_update_database_hourly"), None)
assert row is not None
assert row["trigger_type"] == "schedule" and row["trigger_config"]["unit"] == "hours"
assert row["action_config"]["mode"] == "incremental" # cheap read, not a deep scan
def test_video_system_automations_are_sorted_by_pipeline_order():
# The API returns newest-created-first (jumbled); the page re-sorts by an
# explicit order so it reads scans → processors → library → maintenance.

View file

@ -42,11 +42,39 @@ def test_cards_expand_into_a_detail_drawer():
assert "/downloads/meta/" in _JS
def test_download_meta_route_is_registered():
def test_drawer_renders_the_rich_tmdb_fields():
# cast PHOTOS (the bug was the wrong field name) + logo header + trailer + providers
assert "c.photo" in _JS # correct TMDB cast-photo field
assert "vdpg-dr-logo" in _JS # title logo header
assert "vdpg-dr-trailer" in _JS and "vdpg-prov" in _JS # trailer + where-to-watch
assert "trailer_url" in _JS and "providers" in _JS
def test_youtube_open_button_opens_the_channel_page_not_a_show():
# regression: the open-show button ran parseInt(youtube_id) → opened a random library
# show (id 3 → '3rd Rock'). youtube cards must open the in-app CHANNEL page instead.
open_block = _JS.split("var openBtn")[1].split("var stateBtn")[0]
assert "dlType(d.kind) === 'youtube'" in open_block
assert "data-vdpg-open-channel" in open_block # opens the in-app channel page
assert "youtube.com/watch?v=" in open_block # fallback when the channel id is unknown
# clicking it dispatches an open-detail for the channel (reuses the show-detail page)
assert "kind: 'channel', source: 'youtube'" in _JS
def test_drawer_has_episode_youtube_and_availability_blocks():
assert "vdpg-dr-ytthumb" in _JS # youtube big thumbnail header
assert "vdpg-dr-ep" in _JS and "vdpg-dr-epstill" in _JS # episode still + block
assert "ctx.peer" in _JS # grab-time availability snapshot
assert "yt-meta" in _JS # youtube metadata fetch
assert "season=" in _JS and "episode=" in _JS # episode params on the meta fetch
def test_download_meta_routes_are_registered():
import api.video as videoapi
from flask import Flask
app = Flask(__name__)
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
rules = {r.rule for r in app.url_map.iter_rules()}
assert "/api/video/downloads/meta/<kind>/<int:tmdb_id>" in rules
assert "/api/video/downloads/yt-meta/<video_id>" in rules

View file

@ -19,9 +19,10 @@ def test_normalize_fills_and_validates():
"movie_template": " ", "episode_template": "$series/$episode"})
assert d["transfer_mode"] == "move"
assert d["verify_with_ffprobe"] is False
assert organization.default_settings()["save_artwork"] is False # off by default
assert organization.normalize({"save_artwork": 1})["save_artwork"] is True
assert organization.default_settings()["download_subtitles"] is False
assert organization.default_settings()["save_artwork"] is True # on by default (cheap, local)
assert organization.default_settings()["write_nfo"] is True
assert organization.normalize({"save_artwork": 0})["save_artwork"] is False
assert organization.default_settings()["download_subtitles"] is False # opt-in (external API)
assert organization.default_settings()["subtitle_langs"] == "en"
assert organization.normalize({"subtitle_langs": "EN, es"})["subtitle_langs"] == "en,es"
assert d["movie_template"] == organization.DEFAULTS["movie_template"] # blank → default

View file

@ -69,6 +69,15 @@ def test_build_record_movie_shape():
assert [c["filename"] for c in json.loads(rec["candidates"])] == ["other.mkv"] # best excluded
def test_build_record_stashes_peer_availability_in_ctx():
# the chosen source's free-slot/queue/speed snapshot rides in search_ctx for the drawer
item = {"tmdb_id": 5, "title": "M", "year": "1999"}
best = dict(_cand("M.1999.mkv"), slots=1, queue=0, speed=2100000, availability=0.15)
rec = build_download_record(item, best, [best], media_type="movie", target_dir="/m", query="q")
assert json.loads(rec["search_ctx"])["peer"] == {
"slots": 1, "queue": 0, "speed": 2100000, "availability": 0.15}
def test_build_record_episode_shape():
item = {"show_tmdb_id": 9, "show_title": "Breaking Bad", "season_number": 1,
"episode_number": 3, "air_date": "2008-02-10"}

View file

@ -34,6 +34,13 @@ def test_videos_to_enqueue_skips_already_queued_no_cap():
assert [v["video_id"] for v in out] == ["a", "c", "d"] # b in flight; rest ALL kept
def test_enqueue_ctx_carries_channel_id_for_the_drawer():
from core.automation.handlers.video_process_youtube_wishlist import enqueue_ctx
ctx = enqueue_ctx({"channel_id": "UC1", "channel_title": "Chan",
"video_title": "V", "published_at": "2024-01-01"}, {})
assert ctx["channel_id"] == "UC1" and ctx["channel"] == "Chan"
def test_videos_to_enqueue_drops_idless():
assert videos_to_enqueue([{"video_title": "no id"}], []) == []
@ -187,6 +194,18 @@ def test_downloaded_youtube_video_ids_only_completed_youtube(db):
assert set(db.downloaded_youtube_video_ids()) == {"v1"}
def test_youtube_video_detail(db):
conn = db._get_connection()
conn.execute("INSERT INTO youtube_channel_videos (channel_id, youtube_id, title, thumbnail_url, "
"duration, view_count) VALUES (?,?,?,?,?,?)",
("UC1", "vid9", "Cool Vid", "/t.jpg", "12:34", 50000))
conn.commit()
conn.close()
d = db.youtube_video_detail("vid9")
assert d["title"] == "Cool Vid" and d["duration"] == "12:34" and d["view_count"] == 50000
assert db.youtube_video_detail("missing") is None
def test_count_and_claim_queue(db):
a = db.add_video_download({"kind": "youtube", "source": "youtube", "media_id": "v1",
"title": "A", "status": "queued"})

View file

@ -87,6 +87,95 @@ def test_watchlist_continuing_shows_skips_ended_tmdbonly_and_dupes(tmp_path, mon
assert [s["library_id"] for s in out] == [1, 4]
# ── OMDb quota safety ─────────────────────────────────────────────────────────
def test_refresh_skips_omdb_ratings(monkeypatch):
# the bulk refresh must NOT do the per-show OMDb ratings call (it'd burn the daily quota)
import core.automation.handlers.video_refresh_airing_schedules as mod
seen = {}
class _Eng:
def refresh_show_art(self, lib, *, with_ratings=True):
seen["lib"], seen["with_ratings"] = lib, with_ratings
return {"ok": True}
monkeypatch.setattr("core.video.enrichment.engine.get_video_enrichment_engine", lambda: _Eng())
assert mod._default_refresh_show(7) == {"ok": True}
assert seen == {"lib": 7, "with_ratings": False}
def test_omdb_limit_latches_off_and_stops_hammering():
from core.video.enrichment.engine import VideoEnrichmentEngine
from core.video.enrichment.clients import OMDbAuthError
class _Row: # truthy row carrying an imdb id
def __getitem__(self, k):
return "tt123"
class _Conn:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def execute(self, *a):
return type("C", (), {"fetchone": lambda s: _Row()})()
class _DB:
def show_match_info(self, i):
return {"title": "X"}
def connect(self):
return _Conn()
def apply_ratings(self, *a):
pass
class _RC:
enabled = True
def __init__(self):
self.n = 0
def ratings(self, imdb):
self.n += 1
raise OMDbAuthError("Request limit reached!")
eng = VideoEnrichmentEngine.__new__(VideoEnrichmentEngine)
eng.db, eng.ratings_client = _DB(), None
rc = _RC()
eng.workers = {"omdb": type("W", (), {"client": rc})()}
eng._backfill_ratings("show", 1) # hits the limit → latches off (no raise)
assert getattr(eng, "_omdb_blocked", False) is True
eng._backfill_ratings("show", 2) # now short-circuits before calling OMDb
assert rc.n == 1 # only the first attempt ever reached OMDb
def test_tmdb_detail_ratings_share_the_same_latch():
# the detail/drawer path (_fill_tmdb_ratings) must honour + set the SAME latch — it was
# the source of the per-title traceback spam on the download drawer / detail pages.
from core.video.enrichment.engine import VideoEnrichmentEngine
from core.video.enrichment.clients import OMDbAuthError
class _RC:
enabled = True
def __init__(self):
self.n = 0
def ratings(self, imdb):
self.n += 1
raise OMDbAuthError("Request limit reached!")
eng = VideoEnrichmentEngine.__new__(VideoEnrichmentEngine)
rc = _RC()
eng.workers = {"omdb": type("W", (), {"client": rc})()}
eng._fill_tmdb_ratings({"imdb_id": "tt1"}) # hits the limit → latches (no raise out)
assert getattr(eng, "_omdb_blocked", False) is True
eng._fill_tmdb_ratings({"imdb_id": "tt2"}) # short-circuits before calling OMDb
assert rc.n == 1
# ── wiring contract ───────────────────────────────────────────────────────────
def test_seeded_before_the_airing_automation():
import core.automation_engine as ae

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

@ -49,6 +49,15 @@ def test_ydl_opts_carry_format_selection_and_fixed_output():
assert opts["noplaylist"] is True
def test_ydl_opts_wire_the_postprocess_hook_for_the_merge_phase():
# the hook flips the row to 'importing' while ffmpeg merges — so it doesn't sit on 100%
def hook(_d):
return None
opts = ytd.ydl_download_opts(default_profile(), "/d", "stem", postprocess_hook=hook)
assert opts["postprocessor_hooks"] == [hook]
assert "postprocessor_hooks" not in ytd.ydl_download_opts(default_profile(), "/d", "stem")
# ── download_one with an injected yt-dlp ───────────────────────────────────────
class _FakeYDL:
def __init__(self, opts):
@ -174,6 +183,59 @@ def test_process_import_failure_is_terminal_and_keeps_the_wish():
assert calls["unwish"] == [] # not unwished → can retry
def test_build_episode_nfo():
from core.video.youtube_download import build_episode_nfo
nfo = build_episode_nfo({"title": "Cool", "channel": "Chan", "published_at": "2026-06-22",
"youtube_id": "abc"}, description="Hi & <there>", runtime=754)
assert "<title>Cool</title>" in nfo and "<season>2026</season>" in nfo
assert "<episode>622</episode>" in nfo and "<aired>2026-06-22</aired>" in nfo
assert "<studio>Chan</studio>" in nfo and '<uniqueid type="youtube"' in nfo
assert "Hi &amp; &lt;there&gt;" in nfo # xml-escaped
assert "<runtime>13</runtime>" in nfo # 754s ≈ 13 min
def test_default_sidecars_writes_thumb_and_nfo_when_on(tmp_path):
stage, lib = tmp_path / "stage", tmp_path / "lib"
stage.mkdir(); lib.mkdir()
base = "Chan - 2026-06-22 - Vid"
(stage / (base + ".mp4")).write_text("v")
(stage / (base + ".jpg")).write_text("img")
(stage / (base + ".info.json")).write_text('{"description": "Desc", "duration": 754}')
ytd._default_sidecars(str(stage / (base + ".mp4")), str(lib / (base + ".mp4")),
{"title": "Vid", "channel": "Chan", "published_at": "2026-06-22", "youtube_id": "v9"},
{"save_artwork": True, "write_nfo": True})
assert (lib / (base + "-thumb.jpg")).exists() # episode art
nfo = (lib / (base + ".nfo")).read_text()
assert "<title>Vid</title>" in nfo and "<aired>2026-06-22</aired>" in nfo and "Desc" in nfo
assert not (stage / (base + ".info.json")).exists() # mined + dropped
assert not (stage / (base + ".jpg")).exists() # moved out of staging
def test_default_sidecars_off_discards_staged_extras(tmp_path):
stage, lib = tmp_path / "s", tmp_path / "l"
stage.mkdir(); lib.mkdir()
(stage / "V.jpg").write_text("i")
(stage / "V.info.json").write_text("{}")
ytd._default_sidecars(str(stage / "V.mp4"), str(lib / "V.mp4"), {"title": "V"}, {})
assert not (stage / "V.jpg").exists() and not (stage / "V.info.json").exists() # cleaned up
assert not (lib / "V-thumb.jpg").exists() and not (lib / "V.nfo").exists() # nothing written
def test_process_passes_settings_to_sidecars():
calls, update_row, archive, clear = _recorder()
got = {}
def sc(staged, final, fields, settings):
got["settings"], got["fields"] = settings, fields
ytd.process_youtube_download(
_dl(), profile=default_profile(), settings={"write_nfo": True, "save_artwork": False},
download=lambda *a, **k: {"ok": True, "dest_path": "/yt/Chan/Season 2024/x.mp4"},
update_row=update_row, archive=archive, clear_wishlist=clear, sidecars=sc, now=lambda: "t")
assert got["settings"] == {"write_nfo": True, "save_artwork": False}
assert got["fields"]["youtube_id"] == "vid1"
def test_requeue_orphaned_youtube_recovers_only_dead_downloads():
"""After a restart no worker threads survive, so any 'downloading' YouTube row is an
orphan back to 'queued'. A row whose worker is still alive (in _active_worker_ids) and

View file

@ -1774,13 +1774,13 @@ const _autoIcons = {
full_cleanup: '\uD83E\uDDF9',
playlist_pipeline: '\uD83D\uDE80',
// Video side
video_scan_library: '\uD83C\uDFAC', video_scan_server: '\uD83D\uDD04', video_update_database: '\uD83D\uDDC4\uFE0F',
video_scan_library: '\uD83C\uDFAC', video_scan_server: '\uD83D\uDD04', video_update_database: '\uD83D\uDDC4\uFE0F', video_update_database_hourly: '\uD83D\uDDC4\uFE0F',
video_add_airing_episodes: '\uD83D\uDCFA', video_deep_scan_movies: '\uD83C\uDFAC', video_deep_scan_tv: '\uD83D\uDCFA',
video_scan_watchlist_people: '\uD83C\uDFAD', video_scan_watchlist_channels: '\uD83D\uDCE1',
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',
};
@ -3375,7 +3375,8 @@ function _autoFormatAction(type) {
playlist_pipeline: 'Playlist Pipeline',
// Video side
video_scan_library: 'Scan Video Library', video_scan_server: 'Scan Video Server',
video_update_database: 'Update Video Database', video_add_airing_episodes: 'Wishlist Airing Episodes',
video_update_database: 'Update Video Database', video_update_database_hourly: 'Update Video Database (Hourly)',
video_add_airing_episodes: 'Wishlist Airing Episodes',
video_deep_scan_movies: 'Deep Scan Movie Library', video_deep_scan_tv: 'Deep Scan TV Library',
video_scan_watchlist_people: 'Scan Watchlist People',
video_scan_watchlist_channels: 'Scan Watchlist Channels',
@ -3384,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,8 +35,9 @@
'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_deep_scan_tv', 'video_deep_scan_movies',
'video_scan_server', 'video_update_database', 'video_update_database_hourly', 'video_deep_scan_tv', 'video_deep_scan_movies',
// Maintenance
'video_clean_search_history', 'video_clean_completed_downloads', 'video_full_cleanup', 'video_backup_database',
];

View file

@ -154,9 +154,19 @@
var lab = q('label'); if (lab.textContent !== labelTxt) lab.textContent = labelTxt;
var act = q('actions');
var openBtn = d.media_id ? '<button class="vdpg-open" type="button" data-vdpg-open="' + esc(d.media_id) +
'" data-kind="' + esc(d.kind || 'movie') + '" data-source="' + esc(d.media_source || 'library') +
'" title="Open ' + (d.kind === 'movie' ? 'movie' : 'show') + ' page">' + OPEN_SVG + '</button>' : '';
// youtube videos aren't TMDB titles. Open the in-app CHANNEL page (channels-as-shows)
// when we know the channel id; fall back to the YouTube video link otherwise. (The old
// open-show button ran parseInt on the video id → opened a random library show.)
var ytCh = dlType(d.kind) === 'youtube' ? parseCtx(d).channel_id : null;
var openBtn = !d.media_id ? ''
: (dlType(d.kind) === 'youtube'
? (ytCh
? '<button class="vdpg-open" type="button" data-vdpg-open-channel="' + esc(ytCh) + '" title="Open channel">' + OPEN_SVG + '</button>'
: '<a class="vdpg-open" href="https://www.youtube.com/watch?v=' + encodeURIComponent(d.media_id) +
'" target="_blank" rel="noopener" title="Open on YouTube">' + OPEN_SVG + '</a>')
: '<button class="vdpg-open" type="button" data-vdpg-open="' + esc(d.media_id) +
'" data-kind="' + esc(d.kind || 'movie') + '" data-source="' + esc(d.media_source || 'library') +
'" title="Open ' + (d.kind === 'movie' ? 'movie' : 'show') + ' page">' + OPEN_SVG + '</button>');
var stateBtn = active
? '<button class="adl-row-cancel" type="button" data-vdpg-cancel="' + d.id + '" title="Cancel">' + X_SVG + '</button>'
: isFail(d.status)
@ -169,38 +179,116 @@
}
// ── expand drawer ─────────────────────────────────────────────────────────────
function ytCtx(d) {
function parseCtx(d) { // the download's search_ctx (peer/season/episode/channel/…)
try { return d.search_ctx ? (typeof d.search_ctx === 'string' ? JSON.parse(d.search_ctx) : d.search_ctx) : {}; }
catch (e) { return {}; }
}
function fact(k, v) {
return v ? '<div class="vdpg-f"><span class="vdpg-fk">' + esc(k) + '</span><span class="vdpg-fv">' + esc(v) + '</span></div>' : '';
}
function drawerHTML(d, meta) {
var isYt = dlType(d.kind) === 'youtube', ctx = isYt ? ytCtx(d) : {};
var overview = (meta && meta.overview) || ctx.description || '';
var loading = meta === null && !isYt;
var back = (meta && meta.backdrop_url)
? '<div class="vdpg-dr-back" style="background-image:url(\'' + esc(meta.backdrop_url) + '\')"></div>' : '';
var genres = (meta && (meta.genres || []).slice(0, 4).join(' · ')) || '';
var cast = (meta && meta.cast || []).slice(0, 8);
var castHTML = cast.length ? '<div class="vdpg-dr-st">Cast</div><div class="vdpg-dr-cast">' + cast.map(function (c) {
var pic = c.profile_url
? '<span class="vdpg-cast-pic" style="background-image:url(\'' + esc(c.profile_url) + '\')"></span>'
function fmtRuntime(m) {
m = parseInt(m, 10); if (!m) return '';
var h = Math.floor(m / 60), mm = m % 60;
return h ? (h + 'h' + (mm ? ' ' + mm + 'm' : '')) : (mm + 'm');
}
function fmtViews(n) {
n = +n || 0;
return n >= 1e6 ? (Math.round(n / 1e5) / 10 + 'M') : n >= 1e3 ? (Math.round(n / 100) / 10 + 'K') : String(n);
}
function fmtSpeed(bps) {
bps = +bps || 0; if (!bps) return '';
return bps >= 1e6 ? (Math.round(bps / 1e5) / 10 + ' MB/s') : Math.max(1, Math.round(bps / 1e3)) + ' KB/s';
}
function pad2(n) { n = parseInt(n, 10) || 0; return (n < 10 ? '0' : '') + n; }
function castHTMLOf(meta) {
var cast = (meta.cast || []).slice(0, 8);
return cast.length ? '<div class="vdpg-dr-st">Cast</div><div class="vdpg-dr-cast">' + cast.map(function (c) {
var pic = c.photo
? '<span class="vdpg-cast-pic" style="background-image:url(\'' + esc(c.photo) + '\')"></span>'
: '<span class="vdpg-cast-pic vdpg-cast-none">' + esc((c.name || '?').charAt(0)) + '</span>';
return '<div class="vdpg-cast">' + pic + '<span class="vdpg-cast-nm">' + esc(c.name) +
'</span>' + (c.character ? '<span class="vdpg-cast-ch">' + esc(c.character) + '</span>' : '') + '</div>';
}).join('') + '</div>' : '';
}
function drawerHTML(d, meta) {
var isYt = dlType(d.kind) === 'youtube', ctx = parseCtx(d);
var loading = meta === null;
meta = meta || {};
var back = '', head = '', lead = '', extra = '';
if (isYt) {
// big thumbnail + channel · duration · views · upload date, then the description
var thumb = meta.thumbnail_url || d.poster_url;
var yb = [];
if (ctx.channel || ctx.channel_title) yb.push(esc(ctx.channel || ctx.channel_title));
if (meta.duration) yb.push(esc(meta.duration));
if (meta.view_count) yb.push(fmtViews(meta.view_count) + ' views');
if (ctx.published_at) yb.push(esc(String(ctx.published_at).slice(0, 10)));
head = '<div class="vdpg-dr-head">' +
(thumb ? '<div class="vdpg-dr-ytthumb" style="background-image:url(\'' + esc(thumb) + '\')"></div>' : '') +
'<div class="vdpg-dr-title">' + esc(d.title || meta.title || 'Video') + '</div>' +
(yb.length ? '<div class="vdpg-dr-metaline">' + yb.join(' · ') + '</div>' : '') + '</div>';
lead = ctx.description ? '<p class="vdpg-dr-syn">' + esc(ctx.description) + '</p>' : '';
} else {
back = meta.backdrop_url
? '<div class="vdpg-dr-back" style="background-image:url(\'' + esc(meta.backdrop_url) + '\')"></div>' : '';
var titleHTML = meta.logo
? '<img class="vdpg-dr-logo" src="' + esc(meta.logo) + '" alt="' + esc(meta.title || d.title || '') + '">'
: '<div class="vdpg-dr-title">' + esc(meta.title || d.title || 'Download') + '</div>';
var bits = [];
if (meta.year || d.year) bits.push(esc(meta.year || d.year));
if (meta.rating) bits.push('⭐ ' + (Math.round(meta.rating * 10) / 10));
var rt = fmtRuntime(meta.runtime_minutes); if (rt) bits.push(rt);
if (meta.network || meta.studio) bits.push(esc(meta.network || meta.studio));
if (meta.status) bits.push(esc(meta.status));
var tagline = meta.tagline ? '<div class="vdpg-dr-tagline">' + esc(meta.tagline) + '</div>' : '';
head = '<div class="vdpg-dr-head">' + titleHTML +
(bits.length ? '<div class="vdpg-dr-metaline">' + bits.join(' · ') + '</div>' : '') + tagline + '</div>';
var ep = meta.episode;
if (ep) { // the SPECIFIC episode: still + SxE · air date + episode title + its own synopsis
lead = '<div class="vdpg-dr-ep">' +
(ep.still_url ? '<div class="vdpg-dr-epstill" style="background-image:url(\'' + esc(ep.still_url) + '\')"></div>' : '') +
'<div class="vdpg-dr-epbody"><div class="vdpg-dr-epnum">S' + pad2(ep.season) + 'E' + pad2(ep.episode) +
(ep.air_date ? ' · ' + esc(ep.air_date) : '') + '</div>' +
'<div class="vdpg-dr-eptitle">' + esc(ep.title || '') + '</div>' +
(ep.overview ? '<p class="vdpg-dr-epov">' + esc(ep.overview) + '</p>' : '') + '</div></div>';
} else {
lead = loading ? '<p class="vdpg-dr-syn vdpg-dr-muted">Loading…</p>'
: (meta.overview ? '<p class="vdpg-dr-syn">' + esc(meta.overview) + '</p>'
: '<p class="vdpg-dr-syn vdpg-dr-muted">No synopsis available.</p>');
}
var genres = (meta.genres || []).slice(0, 4).join(' · ');
var watch = '';
if (meta.trailer_url) watch += '<a class="vdpg-dr-btn vdpg-dr-trailer" href="' + esc(meta.trailer_url) + '" target="_blank" rel="noopener">▶ Trailer</a>';
var provs = meta.providers || [];
if (provs.length) watch += '<span class="vdpg-dr-provs"><span class="vdpg-dr-provs-t">Watch on</span>' + provs.map(function (p) {
return p.logo ? '<img class="vdpg-prov" src="' + esc(p.logo) + '" alt="' + esc(p.name || '') + '" title="' + esc(p.name || '') + '">'
: '<span class="vdpg-prov vdpg-prov-txt">' + esc(p.name || '') + '</span>';
}).join('') + '</span>';
extra = (genres ? '<div class="vdpg-dr-genres">' + esc(genres) + '</div>' : '') +
(watch ? '<div class="vdpg-dr-watch">' + watch + '</div>' : '') + castHTMLOf(meta);
}
// download facts (only the fields that exist render)
var facts = '';
facts += fact('Status', (STATUS[d.status] || {}).label);
if (isYt) { facts += fact('Channel', ctx.channel || ctx.channel_title); facts += fact('Quality', d.quality_label); }
else {
facts += fact(dlType(d.kind) === 'movie' ? 'Director' : 'Creator', meta.director);
facts += fact('Quality target', d.quality_label);
facts += fact('Release', d.release_title);
facts += fact('Format', [d.resolution, d.source, d.codec].filter(Boolean).join(' · '));
facts += fact('Source', d.username ? ('👤 ' + d.username + (d.queue != null ? (' · queue ' + d.queue) : '')) : '');
facts += fact('Source', d.username ? ('👤 ' + d.username) : '');
if (ctx.peer) { // the chosen source's availability snapshot at grab time
var p = ctx.peer, av = [];
if (p.slots != null) av.push(p.slots > 0 ? '✓ free slot' : 'no free slot');
if (p.queue != null) av.push('queue ' + p.queue);
var sp = fmtSpeed(p.speed); if (sp) av.push(sp);
facts += fact('Availability', av.join(' · '));
}
}
facts += fact('Size', d.size_bytes ? fmtSize(d.size_bytes) : '');
facts += fact('Attempts', d.attempts > 1 ? (d.attempts + 'x') : '');
@ -209,39 +297,52 @@
'<button class="vdpg-copy" type="button" data-vdpg-copy="' + esc(d.dest_path) + '" title="Copy path">⧉</button></div>';
if (isFail(d.status) && d.error) facts += '<div class="vdpg-f vdpg-f-wide vdpg-f-err"><span class="vdpg-fk">Error</span><span class="vdpg-fv">' + esc(d.error) + '</span></div>';
// big actions
var btns = [];
if (d.media_id && !isYt) btns.push('<button class="vdpg-dr-btn" type="button" data-vdpg-open="' + esc(d.media_id) +
'" data-kind="' + esc(d.kind || 'movie') + '" data-source="' + esc(d.media_source || 'library') + '">Open in library</button>');
var ytCh = meta.channel_id || ctx.channel_id;
if (isYt && ytCh) btns.push('<button class="vdpg-dr-btn" type="button" data-vdpg-open-channel="' + esc(ytCh) + '">Open channel</button>');
if (isYt && d.media_id) btns.push('<a class="vdpg-dr-btn" href="https://www.youtube.com/watch?v=' + encodeURIComponent(d.media_id) + '" target="_blank" rel="noopener">Open on YouTube</a>');
if (isActive(d.status)) btns.push('<button class="vdpg-dr-btn vdpg-dr-danger" type="button" data-vdpg-cancel="' + d.id + '">Cancel</button>');
else if (isFail(d.status)) btns.push('<button class="vdpg-dr-btn vdpg-dr-accent" type="button" data-vdpg-retry="' + d.id + '">Retry</button>');
var actions = btns.length ? '<div class="vdpg-dr-actions">' + btns.join('') + '</div>' : '';
var syn = loading ? '<p class="vdpg-dr-syn vdpg-dr-muted">Loading…</p>'
: (overview ? '<p class="vdpg-dr-syn">' + esc(overview) + '</p>'
: (isYt ? '' : '<p class="vdpg-dr-syn vdpg-dr-muted">No synopsis available.</p>'));
return back + '<div class="vdpg-dr-body">' + syn +
(genres ? '<div class="vdpg-dr-genres">' + esc(genres) + '</div>' : '') +
castHTML + '<div class="vdpg-dr-st">Download</div><div class="vdpg-dr-facts">' + facts + '</div>' +
return back + '<div class="vdpg-dr-body">' + head + lead + extra +
'<div class="vdpg-dr-st">Download</div><div class="vdpg-dr-facts">' + facts + '</div>' +
actions + '</div>';
}
function metaURL(d) {
var t = dlType(d.kind);
if (t === 'youtube') return '/api/video/downloads/yt-meta/' + encodeURIComponent(d.media_id);
if (d.media_source === 'library') return null; // owned re-grab: media_id isn't a tmdb id
var url = '/api/video/downloads/meta/' + (t === 'movie' ? 'movie' : 'show') + '/' + encodeURIComponent(d.media_id);
if (d.kind === 'episode') {
var c = parseCtx(d);
if (c.season != null && c.episode != null) url += '?season=' + encodeURIComponent(c.season) + '&episode=' + encodeURIComponent(c.episode);
}
return url;
}
function renderDrawer(el, d) {
var dr = el.querySelector('[data-f="drawer"]'); if (!dr) return;
var open = !!_expanded[d.id];
el.classList.toggle('vdpg-card-open', open);
dr.hidden = !open;
if (!open) { dr.innerHTML = ''; return; }
dr.innerHTML = drawerHTML(d, _meta[d.id]);
// lazily fetch TMDB detail for movie/TV (skip youtube + owned library re-grabs)
if (_meta[d.id] === undefined && d.media_id && dlType(d.kind) !== 'youtube' && d.media_source !== 'library') {
_meta[d.id] = null;
var k = dlType(d.kind) === 'movie' ? 'movie' : 'show';
getJSON('/api/video/downloads/meta/' + k + '/' + encodeURIComponent(d.media_id)).then(function (m) {
_meta[d.id] = m || {};
if (_expanded[d.id]) { var dr2 = el.querySelector('[data-f="drawer"]'); if (dr2) dr2.innerHTML = drawerHTML(d, _meta[d.id]); }
});
// kick off the lazy detail fetch (TMDB for movie/TV, cached metadata for youtube) so the
// first paint already shows 'Loading…' rather than 'no synopsis' flashing before content.
if (_meta[d.id] === undefined && d.media_id) {
var url = metaURL(d);
if (!url) { _meta[d.id] = {}; }
else {
_meta[d.id] = null;
getJSON(url).then(function (m) {
_meta[d.id] = m || {};
if (_expanded[d.id]) { var dr2 = el.querySelector('[data-f="drawer"]'); if (dr2) dr2.innerHTML = drawerHTML(d, _meta[d.id]); }
});
}
}
dr.innerHTML = drawerHTML(d, _meta[d.id]);
}
function render(list) {
@ -342,6 +443,13 @@
}));
return;
}
var och = e.target.closest('[data-vdpg-open-channel]');
if (och) {
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: 'channel', source: 'youtube', id: och.getAttribute('data-vdpg-open-channel') }
}));
return;
}
var cp = e.target.closest('[data-vdpg-copy]');
if (cp) {
var path = cp.getAttribute('data-vdpg-copy');

View file

@ -3806,7 +3806,33 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vdpg-dr-body { position: relative; padding: 16px 18px 18px; }
.vdpg-dr-syn { font-size: 13px; line-height: 1.55; color: rgba(255, 255, 255, 0.82); margin: 0 0 10px; max-width: 80ch; }
.vdpg-dr-muted { color: rgba(255, 255, 255, 0.4); font-style: italic; }
.vdpg-dr-genres { font-size: 11px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: rgb(var(--vt)); }
.vdpg-dr-genres { font-size: 11px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; color: rgb(var(--vt)); margin-bottom: 2px; }
.vdpg-dr-head { margin-bottom: 12px; }
.vdpg-dr-logo { max-height: 54px; max-width: 62%; object-fit: contain; display: block; margin-bottom: 7px;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.55)); }
.vdpg-dr-title { font-size: 20px; font-weight: 800; color: #fff; line-height: 1.15; margin-bottom: 5px; }
.vdpg-dr-metaline { font-size: 12.5px; font-weight: 600; color: rgba(255, 255, 255, 0.62); }
.vdpg-dr-tagline { font-size: 12.5px; font-style: italic; color: rgba(255, 255, 255, 0.5); margin-top: 5px; }
.vdpg-dr-watch { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 14px 0 2px; }
.vdpg-dr-trailer { background: rgba(var(--vt), 0.18); border-color: rgba(var(--vt), 0.5); color: #fff; }
.vdpg-dr-provs { display: inline-flex; align-items: center; gap: 7px; }
.vdpg-dr-provs-t { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255, 255, 255, 0.4); }
.vdpg-prov { width: 28px; height: 28px; border-radius: 7px; object-fit: cover; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); }
.vdpg-prov-txt { display: inline-grid; place-items: center; min-width: 28px; height: 28px; padding: 0 7px; border-radius: 7px;
font-size: 10px; font-weight: 700; background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.8); }
/* youtube: big 16:9 thumbnail in the header */
.vdpg-dr-ytthumb { width: 100%; max-width: 340px; aspect-ratio: 16 / 9; border-radius: 10px; margin-bottom: 10px;
background-size: cover; background-position: center; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); }
/* episode block: still + the specific episode's meta */
.vdpg-dr-ep { display: flex; gap: 16px; margin-bottom: 6px; }
.vdpg-dr-epstill { flex: 0 0 auto; width: 200px; aspect-ratio: 16 / 9; border-radius: 9px;
background-size: cover; background-position: center; background-color: rgba(255, 255, 255, 0.05);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }
.vdpg-dr-epbody { min-width: 0; }
.vdpg-dr-epnum { font-size: 11px; font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase; color: rgb(var(--vt)); }
.vdpg-dr-eptitle { font-size: 15px; font-weight: 700; color: #fff; margin: 3px 0 6px; }
.vdpg-dr-epov { font-size: 12.5px; line-height: 1.5; color: rgba(255, 255, 255, 0.78); margin: 0; }
@media (max-width: 620px) { .vdpg-dr-ep { flex-direction: column; } .vdpg-dr-epstill { width: 100%; max-width: 320px; } }
.vdpg-dr-st { font-size: 10.5px; font-weight: 800; letter-spacing: 0.09em; text-transform: uppercase;
color: rgba(255, 255, 255, 0.4); margin: 16px 0 9px; }
.vdpg-dr-cast { display: flex; flex-wrap: wrap; gap: 12px; }

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 = {