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-shm
|
||||||
database/video_library.db-wal
|
database/video_library.db-wal
|
||||||
database/video_library.db.backup_*
|
database/video_library.db.backup_*
|
||||||
|
database/*.histbak
|
||||||
database/api_call_history.json
|
database/api_call_history.json
|
||||||
storage/image_cache/
|
storage/image_cache/
|
||||||
logs/*.log
|
logs/*.log
|
||||||
|
|
|
||||||
|
|
@ -172,21 +172,62 @@ def register_routes(bp):
|
||||||
|
|
||||||
@bp.route("/downloads/meta/<kind>/<int:tmdb_id>", methods=["GET"])
|
@bp.route("/downloads/meta/<kind>/<int:tmdb_id>", methods=["GET"])
|
||||||
def video_download_meta(kind, tmdb_id):
|
def video_download_meta(kind, tmdb_id):
|
||||||
"""Lazy TMDB detail (overview + cast + backdrop) for a download's expand drawer,
|
"""Lazy TMDB detail for a download's expand drawer (logo, cast w/ photos, trailer,
|
||||||
keyed by the grabbed title's TMDB id. Best-effort — the drawer still shows the
|
where-to-watch, rating/runtime/genres…), keyed by the grabbed title's TMDB id.
|
||||||
download facts without it."""
|
Best-effort — the drawer still shows the download facts without it."""
|
||||||
if kind not in ("movie", "show"):
|
if kind not in ("movie", "show"):
|
||||||
return jsonify({}), 400
|
return jsonify({}), 400
|
||||||
try:
|
try:
|
||||||
from core.video.enrichment.engine import get_video_enrichment_engine
|
from core.video.enrichment.engine import get_video_enrichment_engine
|
||||||
data = get_video_enrichment_engine().tmdb_full_detail(kind, tmdb_id) or {}
|
d = get_video_enrichment_engine().tmdb_full_detail(kind, tmdb_id) or {}
|
||||||
return jsonify({k: data.get(k) for k in
|
if not d:
|
||||||
("title", "overview", "tagline", "backdrop_url", "poster_url",
|
return jsonify({})
|
||||||
"genres", "rating", "cast", "year", "runtime", "status")})
|
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:
|
except Exception:
|
||||||
logger.exception("download meta failed for %s %s", kind, tmdb_id)
|
logger.exception("download meta failed for %s %s", kind, tmdb_id)
|
||||||
return jsonify({})
|
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"])
|
@bp.route("/downloads/quality", methods=["GET"])
|
||||||
def video_quality_profile():
|
def video_quality_profile():
|
||||||
from . import get_video_db
|
from . import get_video_db
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,9 @@ def register_routes(bp):
|
||||||
out["custom_name"] = name
|
out["custom_name"] = name
|
||||||
if body.get("quality"): # falsy/absent → no override (use the global default)
|
if body.get("quality"): # falsy/absent → no override (use the global default)
|
||||||
out["quality"] = normalize_quality(body.get("quality"))
|
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)
|
db.set_channel_settings(channel_id, out)
|
||||||
return jsonify({"success": True, "settings": out})
|
return jsonify({"success": True, "settings": out})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,8 @@ ACTIONS: list[dict] = [
|
||||||
{"value": "show", "label": "TV only"}],
|
{"value": "show", "label": "TV only"}],
|
||||||
"default": "all"}
|
"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
|
# 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
|
# 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.
|
# 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},
|
"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",
|
{"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},
|
"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",
|
{"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,
|
"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": [
|
"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.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_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_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_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_scan_watchlist_channels import auto_video_scan_watchlist_channels
|
||||||
from core.automation.handlers.video_process_youtube_wishlist import auto_video_process_youtube_wishlist
|
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',
|
'video_update_database',
|
||||||
lambda config: auto_video_update_database(config, deps),
|
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).
|
# Sonarr-style: wishlist every episode airing today (for followed shows).
|
||||||
engine.register_action_handler(
|
engine.register_action_handler(
|
||||||
'video_add_airing_episodes',
|
'video_add_airing_episodes',
|
||||||
|
|
@ -239,6 +247,12 @@ def register_all(deps: AutomationDeps) -> None:
|
||||||
'video_refresh_airing_schedules',
|
'video_refresh_airing_schedules',
|
||||||
lambda config: auto_video_refresh_airing_schedules(config, deps),
|
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 ─────────────────────────────────────────
|
# ── Watchlist → Wishlist pipeline ─────────────────────────────────────────
|
||||||
# Stage 1 — SCANS that fill the wishlist from what you follow.
|
# Stage 1 — SCANS that fill the wishlist from what you follow.
|
||||||
# People: wishlist every un-owned movie followed actors/directors made (catalog + upcoming).
|
# 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
|
grab, so the monitor finishes it the same way (other accepted hits become the retry
|
||||||
pool)."""
|
pool)."""
|
||||||
ctx = search_context(item, media_type)
|
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")]
|
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"))
|
media_id = str(item.get("tmdb_id") if media_type == "movie" else item.get("show_tmdb_id"))
|
||||||
return {
|
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 {}
|
cs = channel_settings if isinstance(channel_settings, dict) else {}
|
||||||
ctx = {
|
ctx = {
|
||||||
"channel": cs.get("custom_name") or video.get("channel_title"),
|
"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"),
|
"video_title": video.get("video_title"),
|
||||||
"published_at": video.get("published_at"),
|
"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]:
|
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,
|
"""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
|
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(
|
def auto_video_refresh_airing_schedules(
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,18 @@ SYSTEM_AUTOMATIONS = [
|
||||||
'action_config': {'mode': 'incremental'},
|
'action_config': {'mode': 'incremental'},
|
||||||
'owned_by': 'video',
|
'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
|
# 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
|
# 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
|
# are queued overnight. A fixed wall-clock 'daily_time' (not a rolling 24h interval
|
||||||
|
|
@ -304,6 +316,15 @@ SYSTEM_AUTOMATIONS = [
|
||||||
'initial_delay': 1500,
|
'initial_delay': 1500,
|
||||||
'owned_by': 'video',
|
'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 utils.logging_config import get_logger
|
||||||
|
|
||||||
from .cache import TTLCache
|
from .cache import TTLCache
|
||||||
|
from .clients import OMDbAuthError
|
||||||
from .worker import VideoEnrichmentWorker
|
from .worker import VideoEnrichmentWorker
|
||||||
|
|
||||||
logger = get_logger("video_enrichment.engine")
|
logger = get_logger("video_enrichment.engine")
|
||||||
|
|
@ -62,7 +63,10 @@ class VideoEnrichmentEngine:
|
||||||
# for tests that don't build a worker).
|
# for tests that don't build a worker).
|
||||||
w = self.workers.get("omdb")
|
w = self.workers.get("omdb")
|
||||||
rc = w.client if w else self.ratings_client
|
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
|
return
|
||||||
info = (self.db.movie_match_info(item_id) if kind == "movie"
|
info = (self.db.movie_match_info(item_id) if kind == "movie"
|
||||||
else self.db.show_match_info(item_id))
|
else self.db.show_match_info(item_id))
|
||||||
|
|
@ -81,6 +85,10 @@ class VideoEnrichmentEngine:
|
||||||
ratings = rc.ratings(imdb_id)
|
ratings = rc.ratings(imdb_id)
|
||||||
if ratings:
|
if ratings:
|
||||||
self.db.apply_ratings(kind, item_id, 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:
|
except Exception:
|
||||||
logger.exception("ratings backfill failed for %s %s", kind, item_id)
|
logger.exception("ratings backfill failed for %s %s", kind, item_id)
|
||||||
|
|
||||||
|
|
@ -142,11 +150,15 @@ class VideoEnrichmentEngine:
|
||||||
", ".join(sorted(self._scan_paused)))
|
", ".join(sorted(self._scan_paused)))
|
||||||
self._scan_paused = set()
|
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
|
"""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
|
TMDB, used when the detail page is opened and art is missing. Works
|
||||||
regardless of the show's match status (sidesteps 'already matched, never
|
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")
|
w = self.workers.get("tmdb")
|
||||||
if not w or not w.enabled:
|
if not w or not w.enabled:
|
||||||
return {"ok": False, "reason": "tmdb_not_configured"}
|
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
|
w._cascade_episodes(show_id, result["id"], nums) # full list: owned + missing
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("refresh_show_art: episode cascade failed for show %s", show_id)
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
def refresh_movie_art(self, movie_id) -> dict:
|
def refresh_movie_art(self, movie_id) -> dict:
|
||||||
|
|
@ -554,13 +567,19 @@ class VideoEnrichmentEngine:
|
||||||
def _fill_tmdb_ratings(self, d) -> None:
|
def _fill_tmdb_ratings(self, d) -> None:
|
||||||
imdb_id = d.get("imdb_id")
|
imdb_id = d.get("imdb_id")
|
||||||
ow = self.workers.get("omdb")
|
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
|
return
|
||||||
try:
|
try:
|
||||||
r = ow.client.ratings(imdb_id) or {}
|
r = ow.client.ratings(imdb_id) or {}
|
||||||
for k in ("imdb_rating", "rt_rating", "metacritic"):
|
for k in ("imdb_rating", "rt_rating", "metacritic"):
|
||||||
if r.get(k) is not None:
|
if r.get(k) is not None:
|
||||||
d[k] = r[k]
|
d[k] = r[k]
|
||||||
|
except OMDbAuthError as e:
|
||||||
|
self._omdb_blocked = True
|
||||||
|
logger.warning("OMDb ratings paused for this run: %s", e)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("tmdb_detail ratings failed for %s", imdb_id)
|
logger.exception("tmdb_detail ratings failed for %s", imdb_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,9 @@ DEFAULTS = {
|
||||||
"replace_existing": True,
|
"replace_existing": True,
|
||||||
"transfer_mode": "copy",
|
"transfer_mode": "copy",
|
||||||
"carry_subtitles": True,
|
"carry_subtitles": True,
|
||||||
"save_artwork": False,
|
"save_artwork": True, # nfo + artwork sidecars on by default (cheap, local) — best-in-class
|
||||||
"write_nfo": False,
|
"write_nfo": True,
|
||||||
"download_subtitles": False,
|
"download_subtitles": False, # opt-in: fetches from OpenSubtitles (external, rate-limited)
|
||||||
"subtitle_langs": "en",
|
"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,
|
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,
|
"""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."""
|
a fixed output path (dir + stem + yt-dlp's own ext), polite defaults. Pure."""
|
||||||
sel = format_selection(profile)
|
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"],
|
"merge_output_format": sel["merge_output_format"],
|
||||||
"paths": {"home": str(dest_dir or "")},
|
"paths": {"home": str(dest_dir or "")},
|
||||||
"outtmpl": dest_stem + ".%(ext)s",
|
"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:
|
if cookie_opts:
|
||||||
opts.update(cookie_opts)
|
opts.update(cookie_opts)
|
||||||
if progress_hook:
|
if progress_hook:
|
||||||
opts["progress_hooks"] = [progress_hook]
|
opts["progress_hooks"] = [progress_hook]
|
||||||
|
if postprocess_hook:
|
||||||
|
opts["postprocessor_hooks"] = [postprocess_hook] # fires while ffmpeg merges/converts
|
||||||
return opts
|
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,
|
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
|
"""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."""
|
``{ok, dest_path|None, error|None}``. The yt-dlp class is injectable for tests."""
|
||||||
vid = str(video_id or "").strip()
|
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)
|
factory = ydl_factory or (yt_dlp.YoutubeDL if yt_dlp else None)
|
||||||
if factory is None:
|
if factory is None:
|
||||||
return {"ok": False, "dest_path": None, "error": "yt-dlp unavailable"}
|
return {"ok": False, "dest_path": None, "error": "yt-dlp unavailable"}
|
||||||
opts = ydl_download_opts(profile, dest_dir, dest_stem,
|
opts = ydl_download_opts(profile, dest_dir, dest_stem, progress_hook=progress_hook,
|
||||||
progress_hook=progress_hook, cookie_opts=cookie_opts)
|
postprocess_hook=postprocess_hook, cookie_opts=cookie_opts)
|
||||||
url = vid if vid.startswith("http") else "https://www.youtube.com/watch?v=" + vid
|
url = vid if vid.startswith("http") else "https://www.youtube.com/watch?v=" + vid
|
||||||
try:
|
try:
|
||||||
with factory(opts) as ydl:
|
with factory(opts) as ydl:
|
||||||
|
|
@ -144,6 +153,84 @@ def _default_move(src: str, dest: str) -> None:
|
||||||
shutil.move(src, dest)
|
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(
|
def process_youtube_download(
|
||||||
dl: Dict[str, Any],
|
dl: Dict[str, Any],
|
||||||
*,
|
*,
|
||||||
|
|
@ -155,7 +242,9 @@ def process_youtube_download(
|
||||||
clear_wishlist: Callable[[Any], Any],
|
clear_wishlist: Callable[[Any], Any],
|
||||||
stage_dir: Optional[str] = None,
|
stage_dir: Optional[str] = None,
|
||||||
move: Callable[[str, str], Any] = _default_move,
|
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,
|
progress_hook: Optional[Callable] = None,
|
||||||
|
postprocess_hook: Optional[Callable] = None,
|
||||||
cookie_opts: Optional[dict] = None,
|
cookie_opts: Optional[dict] = None,
|
||||||
now: Optional[Callable[[], str]] = None,
|
now: Optional[Callable[[], str]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
|
|
@ -183,7 +272,7 @@ def process_youtube_download(
|
||||||
update_row(dl.get("id"), status="downloading", progress=0, filename=dest.get("filename"))
|
update_row(dl.get("id"), status="downloading", progress=0, filename=dest.get("filename"))
|
||||||
|
|
||||||
res = download(dl.get("media_id"), dl_dir, stem, profile, cont,
|
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"):
|
if not res.get("ok"):
|
||||||
err = res.get("error") or "Download failed"
|
err = res.get("error") or "Download failed"
|
||||||
|
|
@ -211,6 +300,10 @@ def process_youtube_download(
|
||||||
else:
|
else:
|
||||||
dest_path = staged_path or final_path
|
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()
|
completed = now()
|
||||||
update_row(dl.get("id"), status="completed", progress=100,
|
update_row(dl.get("id"), status="completed", progress=100,
|
||||||
dest_path=dest_path, completed_at=completed)
|
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
|
except Exception: # noqa: BLE001, S110 - a progress glitch must not abort the download
|
||||||
pass
|
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):
|
def _archive(row, upd):
|
||||||
try:
|
try:
|
||||||
db.record_download_history({**row, **upd})
|
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,
|
update_row=db.update_video_download, archive=_archive,
|
||||||
clear_wishlist=lambda vid: db.remove_youtube_from_wishlist("video", vid),
|
clear_wishlist=lambda vid: db.remove_youtube_from_wishlist("video", vid),
|
||||||
stage_dir=stage_dir,
|
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:
|
finally:
|
||||||
_active_worker_ids.discard(dl_id) # worker done — no longer protects this row
|
_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
|
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
|
# per-video duration + approximate view count on the remembered catalog
|
||||||
("youtube_channel_videos", "duration", "TEXT"),
|
("youtube_channel_videos", "duration", "TEXT"),
|
||||||
("youtube_channel_videos", "view_count", "INTEGER"),
|
("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)
|
# fanart.tv artwork backfill (gap-fill only; logo/backdrop/poster live already)
|
||||||
("movies", "clearart_url", "TEXT"), ("movies", "banner_url", "TEXT"),
|
("movies", "clearart_url", "TEXT"), ("movies", "banner_url", "TEXT"),
|
||||||
("movies", "fanart_status", "TEXT"), ("movies", "fanart_attempted", "TEXT"),
|
("movies", "fanart_status", "TEXT"), ("movies", "fanart_attempted", "TEXT"),
|
||||||
|
|
@ -1474,6 +1479,60 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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:
|
def media_tmdb_id(self, kind: str, media_id) -> tuple:
|
||||||
"""(tmdb_id, imdb_id) for a library movie/show row — used to resolve sidecar /
|
"""(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
|
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)
|
"failed": "failed", "cancelled": "cancelled"}.get(status, status)
|
||||||
kind = row.get("kind") or "movie"
|
kind = row.get("kind") or "movie"
|
||||||
media_type = "show" if kind == "show" else ("movie" if kind == "movie" else kind)
|
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.
|
# season/episode + (youtube) channel/upload-date live in the search_ctx JSON.
|
||||||
sn = en = None
|
sn = en = channel_id = published_at = None
|
||||||
ctx = row.get("search_ctx")
|
ctx = row.get("search_ctx")
|
||||||
if ctx:
|
if ctx:
|
||||||
try:
|
try:
|
||||||
ctx = json.loads(ctx) if isinstance(ctx, str) else (ctx or {})
|
ctx = json.loads(ctx) if isinstance(ctx, str) else (ctx or {})
|
||||||
sn, en = ctx.get("season"), ctx.get("episode")
|
sn, en = ctx.get("season"), ctx.get("episode")
|
||||||
|
channel_id, published_at = ctx.get("channel_id"), ctx.get("published_at")
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
pass
|
||||||
rel, fn = row.get("release_title"), row.get("filename")
|
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,
|
(download_id, kind, media_type, title, year, season_number, episode_number,
|
||||||
release_title, source, username, filename, dest_path, size_bytes,
|
release_title, source, username, filename, dest_path, size_bytes,
|
||||||
quality_label, resolution, video_codec, media_id, media_source, poster_url,
|
quality_label, resolution, video_codec, media_id, media_source, poster_url,
|
||||||
outcome, error, grabbed_at, completed_at)
|
outcome, error, grabbed_at, completed_at, channel_id, published_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(row.get("id"), kind, media_type, row.get("title"), row.get("year"), sn, en,
|
(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"),
|
rel, row.get("source"), row.get("username"), fn, row.get("dest_path"),
|
||||||
int(row.get("size_bytes") or 0), row.get("quality_label"),
|
int(row.get("size_bytes") or 0), row.get("quality_label"),
|
||||||
self._parse_resolution(rel, fn, row.get("quality_label")), self._codec(rel, fn),
|
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"),
|
row.get("media_id"), row.get("media_source"), row.get("poster_url"),
|
||||||
outcome, row.get("error"), row.get("created_at"),
|
outcome, row.get("error"), row.get("created_at"),
|
||||||
row.get("completed_at")))
|
row.get("completed_at"), channel_id, published_at))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cur.lastrowid or 0
|
return cur.lastrowid or 0
|
||||||
except Exception:
|
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))
|
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():
|
def test_video_system_automations_are_sorted_by_pipeline_order():
|
||||||
# The API returns newest-created-first (jumbled); the page re-sorts by an
|
# The API returns newest-created-first (jumbled); the page re-sorts by an
|
||||||
# explicit order so it reads scans → processors → library → maintenance.
|
# 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
|
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
|
import api.video as videoapi
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
|
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
|
||||||
rules = {r.rule for r in app.url_map.iter_rules()}
|
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/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"})
|
"movie_template": " ", "episode_template": "$series/$episode"})
|
||||||
assert d["transfer_mode"] == "move"
|
assert d["transfer_mode"] == "move"
|
||||||
assert d["verify_with_ffprobe"] is False
|
assert d["verify_with_ffprobe"] is False
|
||||||
assert organization.default_settings()["save_artwork"] is False # off by default
|
assert organization.default_settings()["save_artwork"] is True # on by default (cheap, local)
|
||||||
assert organization.normalize({"save_artwork": 1})["save_artwork"] is True
|
assert organization.default_settings()["write_nfo"] is True
|
||||||
assert organization.default_settings()["download_subtitles"] is False
|
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.default_settings()["subtitle_langs"] == "en"
|
||||||
assert organization.normalize({"subtitle_langs": "EN, es"})["subtitle_langs"] == "en,es"
|
assert organization.normalize({"subtitle_langs": "EN, es"})["subtitle_langs"] == "en,es"
|
||||||
assert d["movie_template"] == organization.DEFAULTS["movie_template"] # blank → default
|
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
|
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():
|
def test_build_record_episode_shape():
|
||||||
item = {"show_tmdb_id": 9, "show_title": "Breaking Bad", "season_number": 1,
|
item = {"show_tmdb_id": 9, "show_title": "Breaking Bad", "season_number": 1,
|
||||||
"episode_number": 3, "air_date": "2008-02-10"}
|
"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
|
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():
|
def test_videos_to_enqueue_drops_idless():
|
||||||
assert videos_to_enqueue([{"video_title": "no id"}], []) == []
|
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"}
|
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):
|
def test_count_and_claim_queue(db):
|
||||||
a = db.add_video_download({"kind": "youtube", "source": "youtube", "media_id": "v1",
|
a = db.add_video_download({"kind": "youtube", "source": "youtube", "media_id": "v1",
|
||||||
"title": "A", "status": "queued"})
|
"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]
|
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 ───────────────────────────────────────────────────────────
|
# ── wiring contract ───────────────────────────────────────────────────────────
|
||||||
def test_seeded_before_the_airing_automation():
|
def test_seeded_before_the_airing_automation():
|
||||||
import core.automation_engine as ae
|
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
|
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 ───────────────────────────────────────
|
# ── download_one with an injected yt-dlp ───────────────────────────────────────
|
||||||
class _FakeYDL:
|
class _FakeYDL:
|
||||||
def __init__(self, opts):
|
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
|
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():
|
def test_requeue_orphaned_youtube_recovers_only_dead_downloads():
|
||||||
"""After a restart no worker threads survive, so any 'downloading' YouTube row is an
|
"""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
|
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',
|
full_cleanup: '\uD83E\uDDF9',
|
||||||
playlist_pipeline: '\uD83D\uDE80',
|
playlist_pipeline: '\uD83D\uDE80',
|
||||||
// Video side
|
// 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_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_people: '\uD83C\uDFAD', video_scan_watchlist_channels: '\uD83D\uDCE1',
|
||||||
video_scan_watchlist_playlists: '\uD83C\uDFB5',
|
video_scan_watchlist_playlists: '\uD83C\uDFB5',
|
||||||
video_process_movie_wishlist: '\uD83C\uDFAC', video_process_episode_wishlist: '\uD83D\uDCFA',
|
video_process_movie_wishlist: '\uD83C\uDFAC', video_process_episode_wishlist: '\uD83D\uDCFA',
|
||||||
video_process_youtube_wishlist: '\u2B07\uFE0F',
|
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_clean_search_history: '\uD83D\uDDD1\uFE0F', video_clean_completed_downloads: '\u2705',
|
||||||
video_full_cleanup: '\uD83E\uDDF9', video_backup_database: '\uD83D\uDCBE',
|
video_full_cleanup: '\uD83E\uDDF9', video_backup_database: '\uD83D\uDCBE',
|
||||||
};
|
};
|
||||||
|
|
@ -3375,7 +3375,8 @@ function _autoFormatAction(type) {
|
||||||
playlist_pipeline: 'Playlist Pipeline',
|
playlist_pipeline: 'Playlist Pipeline',
|
||||||
// Video side
|
// Video side
|
||||||
video_scan_library: 'Scan Video Library', video_scan_server: 'Scan Video Server',
|
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_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_people: 'Scan Watchlist People',
|
||||||
video_scan_watchlist_channels: 'Scan Watchlist Channels',
|
video_scan_watchlist_channels: 'Scan Watchlist Channels',
|
||||||
|
|
@ -3384,6 +3385,7 @@ function _autoFormatAction(type) {
|
||||||
video_process_episode_wishlist: 'Process Episode Wishlist',
|
video_process_episode_wishlist: 'Process Episode Wishlist',
|
||||||
video_process_youtube_wishlist: 'Process YouTube Wishlist',
|
video_process_youtube_wishlist: 'Process YouTube Wishlist',
|
||||||
video_refresh_airing_schedules: 'Refresh Airing Schedules',
|
video_refresh_airing_schedules: 'Refresh Airing Schedules',
|
||||||
|
video_clean_youtube_episodes: 'Clean Old YouTube Episodes',
|
||||||
video_clean_search_history: 'Clean Search History',
|
video_clean_search_history: 'Clean Search History',
|
||||||
video_clean_completed_downloads: 'Clean Completed Downloads',
|
video_clean_completed_downloads: 'Clean Completed Downloads',
|
||||||
video_full_cleanup: 'Full Cleanup', video_backup_database: 'Backup Database',
|
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',
|
'video_scan_watchlist_playlists', 'video_refresh_airing_schedules', 'video_add_airing_episodes',
|
||||||
// Stage 2 — processors that DRAIN it (download)
|
// Stage 2 — processors that DRAIN it (download)
|
||||||
'video_process_movie_wishlist', 'video_process_episode_wishlist', 'video_process_youtube_wishlist',
|
'video_process_movie_wishlist', 'video_process_episode_wishlist', 'video_process_youtube_wishlist',
|
||||||
|
'video_clean_youtube_episodes',
|
||||||
// Library scan / sync
|
// 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
|
// Maintenance
|
||||||
'video_clean_search_history', 'video_clean_completed_downloads', 'video_full_cleanup', 'video_backup_database',
|
'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 lab = q('label'); if (lab.textContent !== labelTxt) lab.textContent = labelTxt;
|
||||||
|
|
||||||
var act = q('actions');
|
var act = q('actions');
|
||||||
var openBtn = d.media_id ? '<button class="vdpg-open" type="button" data-vdpg-open="' + esc(d.media_id) +
|
// youtube videos aren't TMDB titles. Open the in-app CHANNEL page (channels-as-shows)
|
||||||
'" data-kind="' + esc(d.kind || 'movie') + '" data-source="' + esc(d.media_source || 'library') +
|
// when we know the channel id; fall back to the YouTube video link otherwise. (The old
|
||||||
'" title="Open ' + (d.kind === 'movie' ? 'movie' : 'show') + ' page">' + OPEN_SVG + '</button>' : '';
|
// 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
|
var stateBtn = active
|
||||||
? '<button class="adl-row-cancel" type="button" data-vdpg-cancel="' + d.id + '" title="Cancel">' + X_SVG + '</button>'
|
? '<button class="adl-row-cancel" type="button" data-vdpg-cancel="' + d.id + '" title="Cancel">' + X_SVG + '</button>'
|
||||||
: isFail(d.status)
|
: isFail(d.status)
|
||||||
|
|
@ -169,38 +179,116 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── expand drawer ─────────────────────────────────────────────────────────────
|
// ── 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) : {}; }
|
try { return d.search_ctx ? (typeof d.search_ctx === 'string' ? JSON.parse(d.search_ctx) : d.search_ctx) : {}; }
|
||||||
catch (e) { return {}; }
|
catch (e) { return {}; }
|
||||||
}
|
}
|
||||||
function fact(k, v) {
|
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>' : '';
|
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) {
|
function fmtRuntime(m) {
|
||||||
var isYt = dlType(d.kind) === 'youtube', ctx = isYt ? ytCtx(d) : {};
|
m = parseInt(m, 10); if (!m) return '';
|
||||||
var overview = (meta && meta.overview) || ctx.description || '';
|
var h = Math.floor(m / 60), mm = m % 60;
|
||||||
var loading = meta === null && !isYt;
|
return h ? (h + 'h' + (mm ? ' ' + mm + 'm' : '')) : (mm + 'm');
|
||||||
var back = (meta && meta.backdrop_url)
|
}
|
||||||
? '<div class="vdpg-dr-back" style="background-image:url(\'' + esc(meta.backdrop_url) + '\')"></div>' : '';
|
function fmtViews(n) {
|
||||||
var genres = (meta && (meta.genres || []).slice(0, 4).join(' · ')) || '';
|
n = +n || 0;
|
||||||
var cast = (meta && meta.cast || []).slice(0, 8);
|
return n >= 1e6 ? (Math.round(n / 1e5) / 10 + 'M') : n >= 1e3 ? (Math.round(n / 100) / 10 + 'K') : String(n);
|
||||||
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
|
function fmtSpeed(bps) {
|
||||||
? '<span class="vdpg-cast-pic" style="background-image:url(\'' + esc(c.profile_url) + '\')"></span>'
|
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>';
|
: '<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) +
|
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>';
|
'</span>' + (c.character ? '<span class="vdpg-cast-ch">' + esc(c.character) + '</span>' : '') + '</div>';
|
||||||
}).join('') + '</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)
|
// download facts (only the fields that exist render)
|
||||||
var facts = '';
|
var facts = '';
|
||||||
facts += fact('Status', (STATUS[d.status] || {}).label);
|
facts += fact('Status', (STATUS[d.status] || {}).label);
|
||||||
if (isYt) { facts += fact('Channel', ctx.channel || ctx.channel_title); facts += fact('Quality', d.quality_label); }
|
if (isYt) { facts += fact('Channel', ctx.channel || ctx.channel_title); facts += fact('Quality', d.quality_label); }
|
||||||
else {
|
else {
|
||||||
|
facts += fact(dlType(d.kind) === 'movie' ? 'Director' : 'Creator', meta.director);
|
||||||
facts += fact('Quality target', d.quality_label);
|
facts += fact('Quality target', d.quality_label);
|
||||||
facts += fact('Release', d.release_title);
|
facts += fact('Release', d.release_title);
|
||||||
facts += fact('Format', [d.resolution, d.source, d.codec].filter(Boolean).join(' · '));
|
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('Size', d.size_bytes ? fmtSize(d.size_bytes) : '');
|
||||||
facts += fact('Attempts', d.attempts > 1 ? (d.attempts + 'x') : '');
|
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>';
|
'<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>';
|
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 = [];
|
var btns = [];
|
||||||
if (d.media_id && !isYt) btns.push('<button class="vdpg-dr-btn" type="button" data-vdpg-open="' + esc(d.media_id) +
|
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>');
|
'" 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 (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>');
|
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>');
|
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 actions = btns.length ? '<div class="vdpg-dr-actions">' + btns.join('') + '</div>' : '';
|
||||||
|
|
||||||
var syn = loading ? '<p class="vdpg-dr-syn vdpg-dr-muted">Loading…</p>'
|
return back + '<div class="vdpg-dr-body">' + head + lead + extra +
|
||||||
: (overview ? '<p class="vdpg-dr-syn">' + esc(overview) + '</p>'
|
'<div class="vdpg-dr-st">Download</div><div class="vdpg-dr-facts">' + facts + '</div>' +
|
||||||
: (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>' +
|
|
||||||
actions + '</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) {
|
function renderDrawer(el, d) {
|
||||||
var dr = el.querySelector('[data-f="drawer"]'); if (!dr) return;
|
var dr = el.querySelector('[data-f="drawer"]'); if (!dr) return;
|
||||||
var open = !!_expanded[d.id];
|
var open = !!_expanded[d.id];
|
||||||
el.classList.toggle('vdpg-card-open', open);
|
el.classList.toggle('vdpg-card-open', open);
|
||||||
dr.hidden = !open;
|
dr.hidden = !open;
|
||||||
if (!open) { dr.innerHTML = ''; return; }
|
if (!open) { dr.innerHTML = ''; return; }
|
||||||
dr.innerHTML = drawerHTML(d, _meta[d.id]);
|
// kick off the lazy detail fetch (TMDB for movie/TV, cached metadata for youtube) so the
|
||||||
// lazily fetch TMDB detail for movie/TV (skip youtube + owned library re-grabs)
|
// first paint already shows 'Loading…' rather than 'no synopsis' flashing before content.
|
||||||
if (_meta[d.id] === undefined && d.media_id && dlType(d.kind) !== 'youtube' && d.media_source !== 'library') {
|
if (_meta[d.id] === undefined && d.media_id) {
|
||||||
_meta[d.id] = null;
|
var url = metaURL(d);
|
||||||
var k = dlType(d.kind) === 'movie' ? 'movie' : 'show';
|
if (!url) { _meta[d.id] = {}; }
|
||||||
getJSON('/api/video/downloads/meta/' + k + '/' + encodeURIComponent(d.media_id)).then(function (m) {
|
else {
|
||||||
_meta[d.id] = m || {};
|
_meta[d.id] = null;
|
||||||
if (_expanded[d.id]) { var dr2 = el.querySelector('[data-f="drawer"]'); if (dr2) dr2.innerHTML = drawerHTML(d, _meta[d.id]); }
|
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) {
|
function render(list) {
|
||||||
|
|
@ -342,6 +443,13 @@
|
||||||
}));
|
}));
|
||||||
return;
|
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]');
|
var cp = e.target.closest('[data-vdpg-copy]');
|
||||||
if (cp) {
|
if (cp) {
|
||||||
var path = cp.getAttribute('data-vdpg-copy');
|
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-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-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-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;
|
.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; }
|
color: rgba(255, 255, 255, 0.4); margin: 16px 0 9px; }
|
||||||
.vdpg-dr-cast { display: flex; flex-wrap: wrap; gap: 12px; }
|
.vdpg-dr-cast { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,9 @@
|
||||||
return '<option value="' + v + '"' + (v === sel ? ' selected' : '') + '>' + v + '</option>';
|
return '<option value="' + v + '"' + (v === sel ? ' selected' : '') + '>' + v + '</option>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
function _keepOpt(v, label, sel) {
|
||||||
|
return '<option value="' + v + '"' + ((sel || 'all') === v ? ' selected' : '') + '>' + label + '</option>';
|
||||||
|
}
|
||||||
|
|
||||||
function _csetForm(title, s, q, dq, kind) {
|
function _csetForm(title, s, q, dq, kind) {
|
||||||
var on = !!s.quality; // a stored override → start enabled
|
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-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>' +
|
'<label class="vyt-cset-ck"><input type="checkbox" data-cset-hdr' + (b.allow_hdr ? ' checked' : '') + '> Allow HDR</label>' +
|
||||||
'</div>' +
|
'</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) {
|
function openChannelSettings(channelId, title, kind) {
|
||||||
|
|
@ -270,6 +283,9 @@
|
||||||
saveBtn.addEventListener('click', function () {
|
saveBtn.addEventListener('click', function () {
|
||||||
var nameEl = body.querySelector('[data-cset-name]');
|
var nameEl = body.querySelector('[data-cset-name]');
|
||||||
var payload = { custom_name: (nameEl ? nameEl.value : '').trim() };
|
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]');
|
var qon = body.querySelector('[data-cset-qon]');
|
||||||
if (qon && qon.checked) {
|
if (qon && qon.checked) {
|
||||||
payload.quality = {
|
payload.quality = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue