Compare commits
10 commits
a30a70fafd
...
8eadadc1ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eadadc1ae | ||
|
|
151bb7b542 | ||
|
|
11c0c62a7e | ||
|
|
75f23641b3 | ||
|
|
f6360e90da | ||
|
|
02a319c350 | ||
|
|
ca1348b2c2 | ||
|
|
3f924812da | ||
|
|
b916e6a2f3 | ||
|
|
c35ec43466 |
28 changed files with 1018 additions and 70 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 3–6 months). Removes the video + its sidecars but keeps the history record so it's never re-downloaded. No-op for channels left on 'keep everything' (the default). Playlists are excluded. Pair with a daily Schedule.", "available": True},
|
||||
{"type": "video_add_airing_episodes", "label": "Wishlist Today's Airings", "icon": "calendar", "scope": "video",
|
||||
"description": "Sonarr-style: add every episode airing today (for shows you follow) to the wishlist, skipping ones you already own. Also tidies the watchlist by dropping shows that have ended/been canceled.", "available": True,
|
||||
"config_fields": [
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from core.automation.handlers.run_script import auto_run_script
|
|||
from core.automation.handlers.search_and_download import auto_search_and_download
|
||||
from core.automation.handlers.video_auto_wishlist_airing import auto_video_add_airing_episodes
|
||||
from core.automation.handlers.video_refresh_airing_schedules import auto_video_refresh_airing_schedules
|
||||
from core.automation.handlers.video_clean_youtube import auto_video_clean_youtube_episodes
|
||||
from core.automation.handlers.video_scan_watchlist_people import auto_video_scan_watchlist_people
|
||||
from core.automation.handlers.video_scan_watchlist_channels import auto_video_scan_watchlist_channels
|
||||
from core.automation.handlers.video_process_youtube_wishlist import auto_video_process_youtube_wishlist
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
130
core/automation/handlers/video_clean_youtube.py
Normal file
130
core/automation/handlers/video_clean_youtube.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Automation handler: ``video_clean_youtube_episodes`` action.
|
||||
|
||||
YouTube retention: for each followed channel that has a per-channel retention policy (set in
|
||||
the channel's cog modal — default 'keep everything'), delete the episodes that fall outside
|
||||
the keep window (video file + its ``-thumb``/``.nfo`` sidecars). The history row is KEPT and
|
||||
flagged pruned, so the scan's download-dedup still excludes it — a cleaned episode is never
|
||||
re-downloaded. Playlists are mirror-the-whole-thing, so they're out of scope (channels only).
|
||||
|
||||
Runs daily. Pure — channel list, per-channel policy, episode list, file deletion + the prune
|
||||
mark are all injected seams; tests drive it with fakes and never touch a disk or DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import date
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from core.automation.deps import AutomationDeps
|
||||
from core.video.retention import episodes_to_prune, parse_retention
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _default_fetch_channels() -> List[Any]:
|
||||
from api.video import get_video_db
|
||||
return get_video_db().youtube_channels_with_downloads()
|
||||
|
||||
|
||||
def _default_channel_retention(channel_id: Any) -> Any:
|
||||
from api.video import get_video_db
|
||||
return (get_video_db().get_channel_settings(channel_id) or {}).get("retention")
|
||||
|
||||
|
||||
def _default_fetch_episodes(channel_id: Any) -> List[Dict[str, Any]]:
|
||||
from api.video import get_video_db
|
||||
return get_video_db().youtube_channel_episodes(channel_id)
|
||||
|
||||
|
||||
def _default_mark_pruned(history_id: Any, when: str) -> bool:
|
||||
from api.video import get_video_db
|
||||
return get_video_db().mark_download_pruned(history_id, when)
|
||||
|
||||
|
||||
def _silent_remove(path: str) -> None:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _default_delete_files(ep: Dict[str, Any]):
|
||||
"""Delete the episode's video + its sidecars. Returns (ok, freed_bytes). Only ever touches
|
||||
the exact recorded ``dest_path`` and its same-stem sidecars — never walks a folder. A file
|
||||
already gone counts as success (mark it pruned); a real delete error does NOT (so it retries)."""
|
||||
path = ep.get("dest_path")
|
||||
if not path:
|
||||
return False, 0
|
||||
size = 0
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
size = os.path.getsize(path)
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
logger.exception("retention: could not delete %s", path)
|
||||
return False, 0
|
||||
stem = os.path.splitext(path)[0]
|
||||
for sc in (stem + ".nfo", stem + "-thumb.jpg", stem + "-thumb.jpeg",
|
||||
stem + "-thumb.png", stem + "-thumb.webp"):
|
||||
if os.path.exists(sc):
|
||||
_silent_remove(sc)
|
||||
return True, size
|
||||
|
||||
|
||||
def _fmt_gb(b: int) -> str:
|
||||
gb = (b or 0) / (1024 ** 3)
|
||||
return ("%.1f GB" % gb) if gb >= 0.1 else "%d MB" % round((b or 0) / (1024 ** 2))
|
||||
|
||||
|
||||
def auto_video_clean_youtube_episodes(
|
||||
config: Dict[str, Any],
|
||||
deps: AutomationDeps,
|
||||
*,
|
||||
fetch_channels: Optional[Callable[[], List[Any]]] = None,
|
||||
channel_retention: Optional[Callable[[Any], Any]] = None,
|
||||
fetch_episodes: Optional[Callable[[Any], List[Dict[str, Any]]]] = None,
|
||||
delete_files: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||
mark_pruned: Optional[Callable[[Any, str], Any]] = None,
|
||||
today_fn: Optional[Callable[[], str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete out-of-window episodes for channels with a retention policy. Returns
|
||||
``{'status': 'completed', 'deleted': int, 'channels': int, 'freed_bytes': int, ...}``."""
|
||||
fetch_channels = fetch_channels or _default_fetch_channels
|
||||
channel_retention = channel_retention or _default_channel_retention
|
||||
fetch_episodes = fetch_episodes or _default_fetch_episodes
|
||||
delete_files = delete_files or _default_delete_files
|
||||
mark_pruned = mark_pruned or _default_mark_pruned
|
||||
today_fn = today_fn or (lambda: date.today().isoformat())
|
||||
automation_id = config.get("_automation_id")
|
||||
try:
|
||||
today = today_fn()
|
||||
deps.update_progress(automation_id, phase="Checking retention…", progress=10,
|
||||
log_line="Looking for channels with a retention policy", log_type="info")
|
||||
deleted = freed = checked = 0
|
||||
for cid in (fetch_channels() or []):
|
||||
policy = channel_retention(cid)
|
||||
if not parse_retention(policy): # 'keep everything' / unset → skip
|
||||
continue
|
||||
checked += 1
|
||||
prune = episodes_to_prune(fetch_episodes(cid) or [], policy, today=today)
|
||||
for ep in prune:
|
||||
ok, size = delete_files(ep)
|
||||
if not ok:
|
||||
continue
|
||||
mark_pruned(ep.get("id"), today) # keep the row → scan won't re-download it
|
||||
deleted += 1
|
||||
freed += size or 0
|
||||
deps.update_progress(automation_id, log_type="info",
|
||||
log_line="Removed '%s'" % (ep.get("title") or ep.get("media_id") or "episode"))
|
||||
|
||||
msg = ("Removed %d old episode(s) · freed %s" % (deleted, _fmt_gb(freed))) if deleted \
|
||||
else "Nothing to clean — every channel within its retention window"
|
||||
deps.update_progress(automation_id, status="finished", progress=100, phase="Complete",
|
||||
log_line=msg, log_type="success")
|
||||
return {"status": "completed", "deleted": deleted, "channels": checked,
|
||||
"freed_bytes": freed, "_manages_own_progress": True}
|
||||
except Exception as e: # noqa: BLE001
|
||||
deps.update_progress(automation_id, status="error", phase="Error", log_line=str(e), log_type="error")
|
||||
return {"status": "error", "error": str(e), "_manages_own_progress": True}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
60
core/video/retention.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Pure retention math for the YouTube channel auto-clean.
|
||||
|
||||
A channel's retention setting is a small string the cog modal stores:
|
||||
``all`` — keep everything (default; nothing is ever deleted)
|
||||
``count_<n>`` — keep the newest N episodes by upload date
|
||||
``days_<n>`` — keep episodes uploaded within the last N days
|
||||
|
||||
Episodes are aged by their UPLOAD date (``published_at``), falling back to the date in the
|
||||
filename (the youtube template embeds it) so downloads from before the column existed still
|
||||
work. An episode with no derivable upload date is NEVER pruned (safe). All file I/O lives in
|
||||
the handler; this module just decides which episodes fall outside the keep window.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})")
|
||||
|
||||
|
||||
def parse_retention(value: Any) -> Optional[Tuple[str, int]]:
|
||||
"""``'count_30'`` → ``('count', 30)``; ``'all'`` / blank / junk → None (keep everything)."""
|
||||
if not value or value == "all":
|
||||
return None
|
||||
try:
|
||||
mode, raw = str(value).split("_", 1)
|
||||
n = int(raw)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
return (mode, n) if (mode in ("count", "days") and n > 0) else None
|
||||
|
||||
|
||||
def episode_date(ep: Dict[str, Any]) -> str:
|
||||
"""The episode's upload date (``YYYY-MM-DD``): ``published_at`` if stored, else parsed
|
||||
from the filename. ``''`` when neither yields one."""
|
||||
p = str(ep.get("published_at") or "")[:10]
|
||||
if len(p) == 10 and _DATE_RE.fullmatch(p):
|
||||
return p
|
||||
m = _DATE_RE.search(str(ep.get("filename") or ""))
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def episodes_to_prune(episodes: List[Dict[str, Any]], retention: Any, *, today: str) -> List[Dict[str, Any]]:
|
||||
"""The episodes to DELETE under ``retention`` (newest upload kept first). Pure — episodes
|
||||
with no derivable upload date are kept. ``today`` is an ISO date for the days-based cutoff."""
|
||||
parsed = parse_retention(retention)
|
||||
if not parsed:
|
||||
return []
|
||||
mode, n = parsed
|
||||
dated = [(episode_date(e), e) for e in episodes]
|
||||
dated = sorted([(d, e) for d, e in dated if d], key=lambda t: t[0], reverse=True)
|
||||
if mode == "count":
|
||||
return [e for _, e in dated[n:]] # everything beyond the newest n
|
||||
try:
|
||||
cutoff = (date.fromisoformat(today) - timedelta(days=n)).isoformat()
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return [e for d, e in dated if d < cutoff] # uploaded before the cutoff
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
113
tests/test_video_retention.py
Normal file
113
tests/test_video_retention.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""YouTube channel retention: delete episodes outside a channel's keep window, keep the
|
||||
history row (so the scan never re-downloads them). Pure retention math + the handler with
|
||||
all I/O injected + the DB layer (capture channel/upload-date, the prune-but-keep contract)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from core.automation.handlers.video_clean_youtube import auto_video_clean_youtube_episodes
|
||||
from core.video.retention import episode_date, episodes_to_prune, parse_retention
|
||||
|
||||
|
||||
# ── pure retention math ─────────────────────────────────────────────────────────
|
||||
def test_parse_retention():
|
||||
assert parse_retention("all") is None and parse_retention("") is None
|
||||
assert parse_retention("count_30") == ("count", 30)
|
||||
assert parse_retention("days_90") == ("days", 90)
|
||||
assert parse_retention("count_0") is None and parse_retention("junk") is None
|
||||
|
||||
|
||||
def test_episode_date_prefers_published_then_filename():
|
||||
assert episode_date({"published_at": "2026-06-22"}) == "2026-06-22"
|
||||
assert episode_date({"published_at": "2026-06-22T10:00:00Z"}) == "2026-06-22"
|
||||
assert episode_date({"filename": "Chan - 2025-01-15 - Title.mp4"}) == "2025-01-15" # fallback
|
||||
assert episode_date({"published_at": None, "filename": "no date.mp4"}) == ""
|
||||
|
||||
|
||||
def test_episodes_to_prune_by_count_keeps_newest():
|
||||
eps = [{"id": i, "published_at": "2026-06-%02d" % (i + 1)} for i in range(5)] # 01..05
|
||||
prune = episodes_to_prune(eps, "count_3", today="2026-06-30")
|
||||
assert sorted(e["id"] for e in prune) == [0, 1] # oldest two go
|
||||
|
||||
|
||||
def test_episodes_to_prune_by_days_uses_upload_date():
|
||||
eps = [{"id": "old", "published_at": "2026-01-01"}, {"id": "new", "published_at": "2026-06-20"}]
|
||||
assert [e["id"] for e in episodes_to_prune(eps, "days_90", today="2026-06-30")] == ["old"]
|
||||
|
||||
|
||||
def test_undated_and_keep_all_never_prune():
|
||||
assert episodes_to_prune([{"id": 1}], "count_1", today="2026-06-30") == [] # no date → kept
|
||||
assert episodes_to_prune([{"id": 1, "published_at": "2000-01-01"}], "all", today="2026-06-30") == []
|
||||
|
||||
|
||||
# ── handler (I/O injected) ──────────────────────────────────────────────────────
|
||||
class _Deps:
|
||||
def __init__(self):
|
||||
self.progress = []
|
||||
|
||||
def update_progress(self, automation_id, **kw):
|
||||
self.progress.append(kw)
|
||||
|
||||
|
||||
def _eps():
|
||||
return [{"id": 1, "published_at": "2026-06-01", "title": "old"},
|
||||
{"id": 2, "published_at": "2026-06-20", "title": "new"}]
|
||||
|
||||
|
||||
def test_handler_deletes_out_of_window_marks_pruned_skips_keep_all():
|
||||
deleted, pruned = [], []
|
||||
res = auto_video_clean_youtube_episodes(
|
||||
{"_automation_id": "a"}, _Deps(),
|
||||
fetch_channels=lambda: ["C1", "C2"],
|
||||
channel_retention=lambda c: "count_1" if c == "C1" else "all", # C2 keep-all → skipped
|
||||
fetch_episodes=lambda c: _eps(),
|
||||
delete_files=lambda ep: (deleted.append(ep["id"]) or True, 1000),
|
||||
mark_pruned=lambda hid, when: pruned.append((hid, when)) or True,
|
||||
today_fn=lambda: "2026-06-30")
|
||||
assert res["status"] == "completed" and res["deleted"] == 1 and res["channels"] == 1
|
||||
assert deleted == [1] and pruned == [(1, "2026-06-30")] and res["freed_bytes"] == 1000
|
||||
|
||||
|
||||
def test_handler_keep_all_everywhere_is_a_noop():
|
||||
res = auto_video_clean_youtube_episodes(
|
||||
{"_automation_id": "a"}, _Deps(), fetch_channels=lambda: ["C1"],
|
||||
channel_retention=lambda c: "all", fetch_episodes=lambda c: _eps(),
|
||||
delete_files=lambda e: (True, 0), mark_pruned=lambda h, w: True, today_fn=lambda: "2026-06-30")
|
||||
assert res["deleted"] == 0 and res["channels"] == 0
|
||||
|
||||
|
||||
def test_handler_delete_failure_is_not_marked_pruned():
|
||||
marked = []
|
||||
res = auto_video_clean_youtube_episodes(
|
||||
{"_automation_id": "a"}, _Deps(), fetch_channels=lambda: ["C1"],
|
||||
channel_retention=lambda c: "count_1", fetch_episodes=lambda c: _eps(),
|
||||
delete_files=lambda e: (False, 0), # couldn't delete → retry next run
|
||||
mark_pruned=lambda h, w: marked.append(h), today_fn=lambda: "2026-06-30")
|
||||
assert res["deleted"] == 0 and marked == []
|
||||
|
||||
|
||||
# ── DB layer ────────────────────────────────────────────────────────────────────
|
||||
from database.video_database import VideoDatabase # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
return VideoDatabase(database_path=str(tmp_path / "video_library.db"))
|
||||
|
||||
|
||||
def test_history_captures_channel_and_upload_date_and_prune_keeps_dedup(db):
|
||||
db.record_download_history({
|
||||
"id": 1, "kind": "youtube", "source": "youtube", "media_id": "v1",
|
||||
"status": "completed", "dest_path": "/yt/Chan/Season 2026/Chan - 2026-06-22 - T.mp4",
|
||||
"search_ctx": json.dumps({"channel_id": "UC1", "published_at": "2026-06-22"})})
|
||||
eps = db.youtube_channel_episodes("UC1")
|
||||
assert len(eps) == 1 and eps[0]["published_at"] == "2026-06-22" and eps[0]["media_id"] == "v1"
|
||||
assert db.youtube_channels_with_downloads() == ["UC1"]
|
||||
|
||||
assert db.mark_download_pruned(eps[0]["id"], "2026-06-30") is True
|
||||
assert db.youtube_channel_episodes("UC1") == [] # gone from the retention view
|
||||
assert db.youtube_channels_with_downloads() == []
|
||||
assert "v1" in db.downloaded_youtube_video_ids() # …but still counts as downloaded → no re-grab
|
||||
|
|
@ -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 & <there>" 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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -214,6 +214,9 @@
|
|||
return '<option value="' + v + '"' + (v === sel ? ' selected' : '') + '>' + v + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
function _keepOpt(v, label, sel) {
|
||||
return '<option value="' + v + '"' + ((sel || 'all') === v ? ' selected' : '') + '>' + label + '</option>';
|
||||
}
|
||||
|
||||
function _csetForm(title, s, q, dq, kind) {
|
||||
var on = !!s.quality; // a stored override → start enabled
|
||||
|
|
@ -235,7 +238,17 @@
|
|||
'<label class="vyt-cset-ck"><input type="checkbox" data-cset-fps' + (b.prefer_60fps !== false ? ' checked' : '') + '> Prefer 60fps</label>' +
|
||||
'<label class="vyt-cset-ck"><input type="checkbox" data-cset-hdr' + (b.allow_hdr ? ' checked' : '') + '> Allow HDR</label>' +
|
||||
'</div>' +
|
||||
'<div class="vyt-cset-hint">Off = use the global YouTube quality from Settings.</div>';
|
||||
'<div class="vyt-cset-hint">Off = use the global YouTube quality from Settings.</div>' +
|
||||
// retention — channels only (playlists mirror the whole thing)
|
||||
(kind === 'playlist' ? '' :
|
||||
'<label class="vyt-cset-lbl">Keep</label>' +
|
||||
'<select class="vyt-cset-in" data-cset-keep>' +
|
||||
_keepOpt('all', 'Everything', s.retention) +
|
||||
_keepOpt('count_30', 'Last 30 episodes', s.retention) +
|
||||
_keepOpt('days_90', 'Last 3 months', s.retention) +
|
||||
_keepOpt('days_180', 'Last 6 months', s.retention) +
|
||||
'</select>' +
|
||||
'<div class="vyt-cset-hint">The <strong>Auto-Clean Old YouTube Episodes</strong> automation deletes downloaded episodes outside this window (video + sidecars). The history record is kept, so they’re never re-downloaded.</div>');
|
||||
}
|
||||
|
||||
function openChannelSettings(channelId, title, kind) {
|
||||
|
|
@ -270,6 +283,9 @@
|
|||
saveBtn.addEventListener('click', function () {
|
||||
var nameEl = body.querySelector('[data-cset-name]');
|
||||
var payload = { custom_name: (nameEl ? nameEl.value : '').trim() };
|
||||
var keepEl = body.querySelector('[data-cset-keep]');
|
||||
// '' for 'Everything' → dropped server-side → keep-all (clears any prior policy)
|
||||
payload.retention = (keepEl && keepEl.value !== 'all') ? keepEl.value : '';
|
||||
var qon = body.querySelector('[data-cset-qon]');
|
||||
if (qon && qon.checked) {
|
||||
payload.quality = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue