Video downloads: best-in-class post-process pipeline
End-to-end import for video grabs, mirroring the music side's rigor and the Radarr/Sonarr standard, fully isolated in core/video + api/video. - Importer: parse release -> ffprobe-verify (true resolution, reject corrupt/ samples) -> templated rename into Movie (Year)/ + Show/Season NN/ -> copy or move, carry subtitles, upgrade-replace a worse copy. - Library Organization settings: editable $token path templates + toggles (transfer mode, verify, replace, carry subs, save artwork, write NFO, download subtitles + langs). Stored in video.db; matches the music File Organization section's look. - Sidecar writer: movie.nfo / tvshow.nfo + full artwork set (poster, fanart, clearlogo, season posters) from on-demand TMDB detail, and external .srt from OpenSubtitles. Owned re-grabs resolve their library tmdb_id; tmdb_full_detail bypasses the owned->library redirect so they enrich too. - Import page: surfaces import_failed downloads, resolve by hand (library-first -> TMDB picker -> force-place) or dismiss; fires a library refresh on place. - "Grab whole season": episode-level batch (reuses searchInto + _autoPick). - Brutalist redesign of the download modal sources + result cards. All new logic has seam-level tests (pure parsers/planners + injected I/O); sidecars/subtitles are best-effort and never break an import.
This commit is contained in:
parent
366b5a266d
commit
efa64db04d
29 changed files with 3452 additions and 234 deletions
|
|
@ -51,6 +51,7 @@ def create_video_blueprint() -> Blueprint:
|
|||
from .wishlist import register_routes as reg_wishlist
|
||||
from .youtube import register_routes as reg_youtube
|
||||
from .downloads import register_routes as reg_downloads
|
||||
from .manual_import import register_routes as reg_manual_import
|
||||
from .automations import register_routes as reg_automations
|
||||
reg_dashboard(bp)
|
||||
reg_scan(bp)
|
||||
|
|
@ -66,6 +67,7 @@ def create_video_blueprint() -> Blueprint:
|
|||
reg_wishlist(bp)
|
||||
reg_youtube(bp)
|
||||
reg_downloads(bp)
|
||||
reg_manual_import(bp)
|
||||
reg_automations(bp)
|
||||
|
||||
return bp
|
||||
|
|
|
|||
|
|
@ -135,6 +135,20 @@ def register_routes(bp):
|
|||
body = request.get_json(silent=True) or {}
|
||||
return jsonify(save(get_video_db(), body))
|
||||
|
||||
@bp.route("/organization", methods=["GET"])
|
||||
def video_organization():
|
||||
"""The library-organisation settings: naming templates + post-process toggles."""
|
||||
from . import get_video_db
|
||||
from core.video.organization import load
|
||||
return jsonify(load(get_video_db()))
|
||||
|
||||
@bp.route("/organization", methods=["POST"])
|
||||
def video_organization_save():
|
||||
from . import get_video_db
|
||||
from core.video.organization import save
|
||||
body = request.get_json(silent=True) or {}
|
||||
return jsonify(save(get_video_db(), body))
|
||||
|
||||
@bp.route("/downloads/evaluate", methods=["POST"])
|
||||
def video_quality_evaluate():
|
||||
"""Judge a video file the user already owns against their quality profile —
|
||||
|
|
|
|||
160
api/video/manual_import.py
Normal file
160
api/video/manual_import.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Manual / failed-import resolution API (isolated).
|
||||
|
||||
When a finished download can't be auto-placed it's parked as ``import_failed`` with
|
||||
the file left on disk (``dest_path`` points at it). The Import page surfaces these and
|
||||
lets the user place them by hand:
|
||||
|
||||
GET /api/video/import/failed → the queue of unplaced downloads
|
||||
POST /api/video/import/<id>/place → force-import to the user's chosen identity
|
||||
POST /api/video/import/<id>/dismiss → drop the row (optionally delete the file)
|
||||
|
||||
The identity picker on the page reuses the existing /api/video/search (TMDB, with a
|
||||
``library_id`` annotation for owned titles) — no new search endpoint needed here.
|
||||
Reads only the video engine + video.db; nothing from the music side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("video_api.manual_import")
|
||||
|
||||
_KIND_FOR_SCOPE = {"movie": "movie", "episode": "show"}
|
||||
|
||||
|
||||
def _ctx(row):
|
||||
try:
|
||||
c = json.loads(row.get("search_ctx") or "{}")
|
||||
return c if isinstance(c, dict) else {}
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _failed_view(row):
|
||||
"""The render-ready shape for one unplaced download."""
|
||||
c = _ctx(row)
|
||||
return {
|
||||
"id": row.get("id"),
|
||||
"title": row.get("title"),
|
||||
"kind": row.get("kind"),
|
||||
"year": row.get("year"),
|
||||
"reason": row.get("error"),
|
||||
"file": row.get("dest_path"), # where the file is sitting, unplaced
|
||||
"release_title": row.get("release_title"),
|
||||
"poster_url": row.get("poster_url"),
|
||||
"scope": c.get("scope"),
|
||||
"season": c.get("season"),
|
||||
"episode": c.get("episode"),
|
||||
}
|
||||
|
||||
|
||||
def register_routes(bp):
|
||||
@bp.route("/import/failed", methods=["GET"])
|
||||
def video_import_failed():
|
||||
from . import get_video_db
|
||||
rows = get_video_db().get_import_failed_video_downloads()
|
||||
return jsonify({"success": True, "items": [_failed_view(r) for r in rows]})
|
||||
|
||||
@bp.route("/import/<int:dl_id>/place", methods=["POST"])
|
||||
def video_import_place(dl_id):
|
||||
"""Force-import an unplaced file to the user's chosen identity. Body:
|
||||
{scope, title, year, season, episode, episode_title, media_id}."""
|
||||
from . import get_video_db
|
||||
from core.video import organization
|
||||
from core.video.download_pipeline import target_dir_for
|
||||
from core.video.importer import real_fs, run_import
|
||||
from core.video.mediainfo import probe
|
||||
|
||||
db = get_video_db()
|
||||
row = db.get_video_download(dl_id)
|
||||
if not row or row.get("status") != "import_failed":
|
||||
return jsonify({"success": False, "error": "Not an unplaced import."}), 404
|
||||
src = row.get("dest_path")
|
||||
if not src or not os.path.exists(src):
|
||||
return jsonify({"success": False, "error": "The file is no longer on disk."}), 410
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
scope = str(body.get("scope") or "").lower()
|
||||
if scope not in ("movie", "episode"):
|
||||
return jsonify({"success": False, "error": "Choose a movie or an episode."}), 400
|
||||
|
||||
paths = {k: db.get_setting(k) or "" for k in ("movies_path", "tv_path", "youtube_path")}
|
||||
if not paths["movies_path"]:
|
||||
paths["movies_path"] = db.get_setting("transfer_path") or ""
|
||||
override = {
|
||||
"scope": scope,
|
||||
"title": body.get("title"),
|
||||
"year": body.get("year"),
|
||||
"season": body.get("season"),
|
||||
"episode": body.get("episode"),
|
||||
"episode_title": body.get("episode_title"),
|
||||
"media_id": body.get("media_id"),
|
||||
"target_dir": target_dir_for(_KIND_FOR_SCOPE[scope], paths),
|
||||
}
|
||||
settings = organization.load(db)
|
||||
prober = probe if settings.get("verify_with_ffprobe", True) else None
|
||||
patch = run_import(row, src, fs=real_fs(), prober=prober, settings=settings,
|
||||
force=True, override=override)
|
||||
try:
|
||||
db.update_video_download(dl_id, **patch)
|
||||
except Exception:
|
||||
logger.exception("manual place: failed to persist import %s", dl_id)
|
||||
return jsonify({"success": False, "error": "Couldn't save the result."}), 500
|
||||
ok = patch.get("status") == "completed"
|
||||
if ok:
|
||||
# Write NFO + artwork sidecars for the chosen identity (best-effort), then
|
||||
# refresh the server + DB the same way the auto-download path does
|
||||
# (batch-complete → scan chain), so the manually-placed title shows up
|
||||
# without waiting for a scheduled scan.
|
||||
sidecar_dl = {"kind": _KIND_FOR_SCOPE[scope], "media_source": "tmdb",
|
||||
"media_id": override.get("media_id"),
|
||||
"poster_url": row.get("poster_url"),
|
||||
"search_ctx": json.dumps({"scope": scope, "season": override.get("season"),
|
||||
"episode": override.get("episode")})}
|
||||
if settings.get("save_artwork") or settings.get("write_nfo"):
|
||||
try:
|
||||
from core.video.download_monitor import write_sidecars
|
||||
from core.video.importer import real_fs
|
||||
write_sidecars(db, sidecar_dl, patch["dest_path"], settings, real_fs())
|
||||
except Exception:
|
||||
logger.exception("manual place: sidecar write failed for %s", dl_id)
|
||||
if settings.get("download_subtitles"):
|
||||
try:
|
||||
from core.video.download_monitor import write_subtitles_for
|
||||
from core.video.importer import real_fs
|
||||
write_subtitles_for(db, sidecar_dl, patch["dest_path"], settings, real_fs())
|
||||
except Exception:
|
||||
logger.exception("manual place: subtitle fetch failed for %s", dl_id)
|
||||
try:
|
||||
from core.video.download_events import notify_batch_complete
|
||||
notify_batch_complete({"completed": 1, "manual": True})
|
||||
except Exception:
|
||||
logger.exception("manual place: batch-complete notify failed for %s", dl_id)
|
||||
return jsonify({"success": ok, "status": patch.get("status"),
|
||||
"dest_path": patch.get("dest_path"), "error": patch.get("error")})
|
||||
|
||||
@bp.route("/import/<int:dl_id>/dismiss", methods=["POST"])
|
||||
def video_import_dismiss(dl_id):
|
||||
"""Drop a failed-import row. Body {delete_file: bool} optionally removes the
|
||||
unplaced file from disk too."""
|
||||
from . import get_video_db
|
||||
db = get_video_db()
|
||||
row = db.get_video_download(dl_id)
|
||||
if not row or row.get("status") != "import_failed":
|
||||
return jsonify({"success": False, "error": "Not an unplaced import."}), 404
|
||||
body = request.get_json(silent=True) or {}
|
||||
if body.get("delete_file"):
|
||||
src = row.get("dest_path")
|
||||
if src and os.path.exists(src):
|
||||
try:
|
||||
os.remove(src)
|
||||
except OSError:
|
||||
logger.warning("dismiss: could not delete %s", src)
|
||||
db.update_video_download(dl_id, status="cancelled",
|
||||
error="Dismissed from manual import")
|
||||
return jsonify({"success": True})
|
||||
|
|
@ -37,12 +37,17 @@ _started = False
|
|||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _complete_via_file(dl, download_dir, lister, mover):
|
||||
"""Locate the finished file in the download dir and move it to the library. Returns
|
||||
a completed/failed patch, or {'progress':100} if the file isn't there yet."""
|
||||
def _complete_via_file(dl, download_dir, lister, mover, organizer):
|
||||
"""Locate the finished file in the download dir and post-process it into the
|
||||
library. Returns a completed/import_failed patch, or {'progress':100} if the file
|
||||
isn't on disk yet. ``organizer(dl, src)`` (when supplied) runs the full Radarr-style
|
||||
import (parse → templated rename → copy/replace → carry subs); otherwise we fall
|
||||
back to the legacy flat move by basename."""
|
||||
src = find_completed_file(download_dir, dl.get("filename"), lister)
|
||||
if not src:
|
||||
return {"progress": 100.0}
|
||||
if organizer is not None:
|
||||
return organizer(dl, src)
|
||||
dest = dest_path_for(dl.get("target_dir"), src)
|
||||
try:
|
||||
mover(src, dest)
|
||||
|
|
@ -51,7 +56,7 @@ def _complete_via_file(dl, download_dir, lister, mover):
|
|||
return {"status": "completed", "progress": 100.0, "dest_path": dest}
|
||||
|
||||
|
||||
def process_download(dl: dict, transfers: list, download_dir: str, *, lister, mover) -> dict | None:
|
||||
def process_download(dl: dict, transfers: list, download_dir: str, *, lister, mover, organizer=None) -> dict | None:
|
||||
"""Decide the next state for one active download given the current slskd transfers.
|
||||
Returns a patch dict for the DB row, or {'_missing': True} when slskd no longer
|
||||
knows the transfer (the caller decides when to give up). Robust to slskd clearing
|
||||
|
|
@ -60,7 +65,7 @@ def process_download(dl: dict, transfers: list, download_dir: str, *, lister, mo
|
|||
t = find_transfer(transfers, dl.get("username"), dl.get("filename"))
|
||||
if not t:
|
||||
# slskd forgot it — could be done+cleared. If the file's there, finish it.
|
||||
done = _complete_via_file(dl, download_dir, lister, mover)
|
||||
done = _complete_via_file(dl, download_dir, lister, mover, organizer)
|
||||
if done.get("status"):
|
||||
return done
|
||||
return {"_missing": True}
|
||||
|
|
@ -71,7 +76,7 @@ def process_download(dl: dict, transfers: list, download_dir: str, *, lister, mo
|
|||
return {"status": "cancelled", "error": "Cancelled on Soulseek"}
|
||||
if state == "failed":
|
||||
return {"status": "failed", "error": "Soulseek transfer " + str(t.get("state") or "failed")}
|
||||
return _complete_via_file(dl, download_dir, lister, mover) # completed
|
||||
return _complete_via_file(dl, download_dir, lister, mover, organizer) # completed
|
||||
|
||||
|
||||
def _move(src: str, dest: str) -> None:
|
||||
|
|
@ -79,6 +84,117 @@ def _move(src: str, dest: str) -> None:
|
|||
shutil.move(src, dest)
|
||||
|
||||
|
||||
def _make_organizer(db):
|
||||
"""A per-tick organizer closure: post-process a finished download into the library
|
||||
via the importer (Radarr-style parse → ffprobe-verify → templated rename →
|
||||
copy/replace → carry subs) against the real filesystem. The upgrade decision reads
|
||||
the destination folder (filesystem-as-truth), so no DB/profile lookup is needed.
|
||||
ffprobe is best-effort — when it isn't installed, ``probe`` returns None and the
|
||||
importer falls back to scene-name parsing."""
|
||||
from core.video import organization
|
||||
from core.video.importer import real_fs, run_import
|
||||
from core.video.mediainfo import probe
|
||||
fs = real_fs()
|
||||
try:
|
||||
settings = organization.load(db)
|
||||
except Exception: # noqa: BLE001 - a settings-load hiccup must never wedge the monitor
|
||||
settings = organization.default_settings()
|
||||
prober = probe if settings.get("verify_with_ffprobe", True) else None
|
||||
|
||||
def organize(dl, src):
|
||||
patch = run_import(dl, src, fs=fs, prober=prober, settings=settings)
|
||||
if patch.get("status") == "completed" and patch.get("dest_path"):
|
||||
if settings.get("save_artwork") or settings.get("write_nfo"):
|
||||
write_sidecars(db, dl, patch["dest_path"], settings, fs)
|
||||
if settings.get("download_subtitles"):
|
||||
write_subtitles_for(db, dl, patch["dest_path"], settings, fs)
|
||||
return patch
|
||||
|
||||
return organize
|
||||
|
||||
|
||||
def _media_ids(db, dl):
|
||||
"""(tmdb_id, imdb_id) for a download's title — taken directly when it was grabbed
|
||||
from TMDB, or looked up from the LIBRARY row for an owned re-grab (whose ``media_id``
|
||||
is the library id, not a TMDB id). (None, None) when it can't be resolved."""
|
||||
dl = dl or {}
|
||||
mid = dl.get("media_id")
|
||||
if not mid:
|
||||
return (None, None)
|
||||
src = str(dl.get("media_source") or "").lower()
|
||||
if src == "tmdb":
|
||||
try:
|
||||
return (int(mid), None)
|
||||
except (TypeError, ValueError):
|
||||
return (None, None)
|
||||
if src == "library" and db is not None:
|
||||
try:
|
||||
kind = "movie" if str(dl.get("kind") or "").lower() == "movie" else "show"
|
||||
return db.media_tmdb_id(kind, mid)
|
||||
except Exception: # noqa: BLE001
|
||||
return (None, None)
|
||||
return (None, None)
|
||||
|
||||
|
||||
def write_sidecars(db, dl, dest_path, settings, fs):
|
||||
"""Best-effort: fetch full TMDB metadata for the imported title (resolving a library
|
||||
re-grab's id when needed) and write NFO + the artwork set next to it — movie folder,
|
||||
or the show root for an episode. Uses the detail's ABSOLUTE image URLs (the download
|
||||
row's poster is an internal/relative path, not fetchable). Never raises. Shared by
|
||||
the monitor and the manual-import endpoint."""
|
||||
try:
|
||||
from core.video import sidecars
|
||||
scope = "movie" if str(dl.get("kind") or "").lower() == "movie" else "episode"
|
||||
tmdb_id, _imdb = _media_ids(db, dl)
|
||||
detail = None
|
||||
if tmdb_id is not None:
|
||||
try:
|
||||
from core.video.enrichment.engine import get_video_enrichment_engine
|
||||
# full_detail (not tmdb_detail) so OWNED titles don't redirect — sidecars
|
||||
# need the raw metadata + absolute image URLs regardless of ownership.
|
||||
d = get_video_enrichment_engine().tmdb_full_detail(
|
||||
"movie" if scope == "movie" else "show", tmdb_id)
|
||||
if isinstance(d, dict):
|
||||
detail = d
|
||||
except Exception: # noqa: BLE001 - a metadata fetch hiccup → skip, don't fail
|
||||
detail = None
|
||||
sidecars.write_for(dest_path, scope, (detail or {}).get("poster_url"), detail, settings, fs)
|
||||
except Exception: # noqa: BLE001 - sidecars are a nice-to-have, never fatal
|
||||
logger.exception("sidecar write failed for download %s", (dl or {}).get("id"))
|
||||
|
||||
|
||||
def write_subtitles_for(db, dl, dest_path, settings, fs):
|
||||
"""Best-effort: download external .srt files (OpenSubtitles) next to the imported
|
||||
video for the user's preferred languages. The .srt sits NEXT TO the file (so an
|
||||
episode's subs land in its Season folder, not the show root). Never raises."""
|
||||
try:
|
||||
import json as _json
|
||||
from core.video import subtitles
|
||||
api_key = db.get_setting("opensubtitles_api_key") if db else None
|
||||
fetch = subtitles.opensubtitles_fetcher(api_key)
|
||||
if not fetch:
|
||||
return
|
||||
tmdb_id, imdb_id = _media_ids(db, dl)
|
||||
identity = {}
|
||||
if tmdb_id is not None:
|
||||
identity["tmdb_id"] = tmdb_id
|
||||
if imdb_id:
|
||||
identity["imdb_id"] = imdb_id
|
||||
try:
|
||||
ctx = _json.loads(dl.get("search_ctx") or "{}")
|
||||
except (ValueError, TypeError):
|
||||
ctx = {}
|
||||
if isinstance(ctx, dict) and ctx.get("season") is not None:
|
||||
identity["season"] = ctx.get("season")
|
||||
identity["episode"] = ctx.get("episode")
|
||||
if not (identity.get("tmdb_id") or identity.get("imdb_id")):
|
||||
return
|
||||
langs = subtitles.parse_langs(settings.get("subtitle_langs"))
|
||||
subtitles.write_subtitles(dest_path, langs, identity, fetch, fs)
|
||||
except Exception: # noqa: BLE001 - subtitle fetch is best-effort, never fatal
|
||||
logger.exception("subtitle fetch failed for download %s", (dl or {}).get("id"))
|
||||
|
||||
|
||||
def _walk(root: str):
|
||||
for dirpath, _dirs, files in os.walk(str(root or ".")):
|
||||
for f in files:
|
||||
|
|
@ -233,11 +349,13 @@ def _tick(db) -> None:
|
|||
from config.settings import config_manager
|
||||
download_dir = str(config_manager.get("soulseek.download_path", "") or "")
|
||||
transfers = list_downloads()
|
||||
organizer = _make_organizer(db)
|
||||
live_ids = set()
|
||||
completed_now = 0
|
||||
for dl in active:
|
||||
live_ids.add(dl["id"])
|
||||
upd = process_download(dl, transfers, download_dir, lister=_walk, mover=_move)
|
||||
upd = process_download(dl, transfers, download_dir, lister=_walk, mover=_move,
|
||||
organizer=organizer)
|
||||
if not upd:
|
||||
continue
|
||||
if upd.get("status") == "completed":
|
||||
|
|
@ -253,7 +371,10 @@ def _tick(db) -> None:
|
|||
if upd.get("status") == "failed":
|
||||
_fail_or_retry(db, dl, upd.get("error")) # auto-retry before truly failing
|
||||
continue
|
||||
if upd.get("status") in ("completed", "cancelled"):
|
||||
# import_failed = the file downloaded fine but couldn't be placed (sample, wrong
|
||||
# episode, not an upgrade, …). Terminal + needs manual import — NOT a download
|
||||
# failure, so don't burn the retry budget re-downloading the same good file.
|
||||
if upd.get("status") in ("completed", "cancelled", "import_failed"):
|
||||
upd.setdefault("completed_at", _now())
|
||||
try:
|
||||
db.update_video_download(dl["id"], **upd)
|
||||
|
|
|
|||
|
|
@ -435,6 +435,19 @@ class VideoEnrichmentEngine:
|
|||
return None
|
||||
return cached or None
|
||||
|
||||
def tmdb_full_detail(self, kind, tmdb_id) -> dict | None:
|
||||
"""Raw TMDB full detail (absolute image URLs + metadata) WITHOUT the
|
||||
owned→library redirect — for sidecar / NFO writing, which needs the data even
|
||||
for titles already in the library."""
|
||||
w = self.workers.get("tmdb")
|
||||
if not w or not w.enabled:
|
||||
return None
|
||||
try:
|
||||
return w.client.full_detail(kind, tmdb_id, region=self._region())
|
||||
except Exception:
|
||||
logger.exception("tmdb_full_detail failed for %s %s", kind, tmdb_id)
|
||||
return None
|
||||
|
||||
def tmdb_detail(self, kind, tmdb_id) -> dict | None:
|
||||
"""Full detail for a TMDB title not in the library — same shape as the
|
||||
library detail (source='tmdb', direct image URLs, nothing owned). If it IS
|
||||
|
|
|
|||
386
core/video/importer.py
Normal file
386
core/video/importer.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
"""Post-process a finished video download into the library — the Radarr/Sonarr step.
|
||||
|
||||
On completion the monitor hands us the located file. We:
|
||||
1. parse the release + SANITY-GATE it (reject non-video / samples / wrong episode /
|
||||
multi-file packs we can't safely place),
|
||||
2. build the canonical library path (``library_paths``),
|
||||
3. decide IMPORT vs UPGRADE-replace vs not-an-upgrade by looking at what is already
|
||||
in the destination folder — the filesystem is the source of truth (like Radarr),
|
||||
which avoids leaning on DB columns the schema doesn't have,
|
||||
4. COPY it in (renamed), carry sibling subtitles, on an upgrade delete the worse
|
||||
existing file, and remove the source unless it's a torrent (preserve seeding).
|
||||
|
||||
``plan_import`` is pure (directory reads injected via ``list_dir``); ``run_import``
|
||||
executes the plan through an injected ``fs`` facade so orchestration is unit-tested
|
||||
without touching disk. Isolated — sibling video modules + stdlib only; no music imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any, Callable
|
||||
|
||||
from core.video import organization
|
||||
from core.video.download_pipeline import basename_of
|
||||
from core.video.library_paths import quality_full
|
||||
from core.video.quality_eval import resolution_rank
|
||||
from core.video.release_parse import parse_release
|
||||
|
||||
VIDEO_EXTS = frozenset({
|
||||
".mkv", ".mp4", ".avi", ".m4v", ".mov", ".ts", ".wmv",
|
||||
".mpg", ".mpeg", ".webm", ".flv", ".m2ts",
|
||||
})
|
||||
SUB_EXTS = frozenset({".srt", ".sub", ".ass", ".ssa", ".idx", ".vtt", ".smi"})
|
||||
|
||||
_SAMPLE_MAX_BYTES = 150 * 1024 * 1024 # a "sample"-named file under this is a sample
|
||||
_SAMPLE = re.compile(r"(^|[.\-_ ])sample([.\-_ ]|$)", re.I)
|
||||
_SXXEXX = re.compile(r"\bS(\d{1,2})[ .]?E(\d{1,3})\b", re.I)
|
||||
|
||||
# A probed runtime under this (seconds) is a sample/clip, not the real thing. Movies
|
||||
# get a generous floor; episodes vary wildly (shorts/cartoons) so only the absurdly
|
||||
# short get caught.
|
||||
_RUNTIME_FLOOR = {"movie": 15 * 60, "episode": 90}
|
||||
|
||||
# Source ranking for the upgrade comparison (mirrors the quality ladder order).
|
||||
_SRC_RANK = {"remux": 6, "bluray": 5, "web-dl": 4, "webrip": 3, "hdtv": 2, "dvd": 1}
|
||||
|
||||
|
||||
def ext_of(path: Any) -> str:
|
||||
"""Lower-cased extension (with dot) of a path's basename, '' if none."""
|
||||
return os.path.splitext(basename_of(path))[1].lower()
|
||||
|
||||
|
||||
def is_video(path: Any) -> bool:
|
||||
return ext_of(path) in VIDEO_EXTS
|
||||
|
||||
|
||||
def is_sample(name: Any, size_bytes: Any) -> bool:
|
||||
"""A 'sample'-tagged file that's also small (or of unknown size) is a sample."""
|
||||
if not _SAMPLE.search(basename_of(name)):
|
||||
return False
|
||||
try:
|
||||
sz = int(size_bytes or 0)
|
||||
except (TypeError, ValueError):
|
||||
sz = 0
|
||||
return sz == 0 or sz < _SAMPLE_MAX_BYTES
|
||||
|
||||
|
||||
def quality_score(parsed: Any) -> int:
|
||||
"""A self-contained quality score (resolution dominates, source breaks ties) used
|
||||
only for the local upgrade comparison. Higher = better."""
|
||||
parsed = parsed if isinstance(parsed, dict) else {}
|
||||
return resolution_rank(parsed.get("resolution")) * 10 + _SRC_RANK.get(parsed.get("source"), 0)
|
||||
|
||||
|
||||
def _scope_of(dl: dict) -> str:
|
||||
"""The import scope from the search context, falling back to the download kind.
|
||||
Only 'movie' and 'episode' are placeable; packs/youtube are gated out upstream."""
|
||||
ctx = _search_ctx(dl)
|
||||
sc = str(ctx.get("scope") or "").lower()
|
||||
if sc in ("movie", "episode", "season", "series"):
|
||||
return sc
|
||||
k = str(dl.get("kind") or "").lower()
|
||||
if k == "movie":
|
||||
return "movie"
|
||||
if k in ("show", "tv", "episode"):
|
||||
return "episode"
|
||||
return k or "movie"
|
||||
|
||||
|
||||
def _search_ctx(dl: dict) -> dict:
|
||||
try:
|
||||
ctx = json.loads((dl or {}).get("search_ctx") or "{}")
|
||||
return ctx if isinstance(ctx, dict) else {}
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _ctx(dl: dict) -> dict:
|
||||
sc = _search_ctx(dl)
|
||||
return {
|
||||
"title": sc.get("title") or (dl or {}).get("title") or "",
|
||||
"year": sc.get("year") if sc.get("year") is not None else (dl or {}).get("year"),
|
||||
"season": sc.get("season"),
|
||||
"episode": sc.get("episode"),
|
||||
"episode_title": sc.get("episode_title"),
|
||||
}
|
||||
|
||||
|
||||
def _reject(reason: str) -> dict:
|
||||
return {"action": "reject", "reason": reason, "manual": True}
|
||||
|
||||
|
||||
def _existing_match(scope: str, dest_dir: str, ctx: dict, list_dir: Callable) -> str | None:
|
||||
"""The basename of a file ALREADY in the destination folder that represents this
|
||||
same item (any video for a movie; a matching SxxExx for an episode), or None.
|
||||
``list_dir(dir)`` yields basenames; a missing dir yields nothing."""
|
||||
try:
|
||||
names = [str(n) for n in (list_dir(dest_dir) or [])]
|
||||
except Exception: # noqa: BLE001 - a missing/denied dir simply means "nothing there"
|
||||
return None
|
||||
vids = [n for n in names if ext_of(n) in VIDEO_EXTS and not is_sample(n, None)]
|
||||
if scope == "movie":
|
||||
return max(vids, key=len) if vids else None
|
||||
if scope == "episode":
|
||||
try:
|
||||
ws, we = int(ctx.get("season")), int(ctx.get("episode"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
for n in vids:
|
||||
m = _SXXEXX.search(n)
|
||||
if m and int(m.group(1)) == ws and int(m.group(2)) == we:
|
||||
return n
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def plan_import(dl: dict, src_path: str, *, list_dir: Callable, probe: dict | None = None,
|
||||
settings: dict | None = None, force: bool = False,
|
||||
override: dict | None = None) -> dict:
|
||||
"""Decide what to do with a finished download. Returns one of:
|
||||
|
||||
{"action": "import", "dest": {...}, "quality_label": str}
|
||||
{"action": "upgrade", "dest": {...}, "replace_path": str, "quality_label": str}
|
||||
{"action": "reject", "reason": str, "manual": True}
|
||||
|
||||
Pure: all directory reads go through ``list_dir`` (injected). ``probe`` is the
|
||||
ffprobe ``mediainfo`` result (or None when ffprobe is unavailable) — when present
|
||||
we trust the FILE's real resolution over the scene name and reject corrupt /
|
||||
too-short junk. ``settings`` are the user's organisation settings (naming templates
|
||||
+ replace policy); None = defaults.
|
||||
|
||||
MANUAL placement: ``force=True`` with an ``override`` ({scope, title, year, season,
|
||||
episode, episode_title, target_dir, media_id}) trusts the user's chosen identity —
|
||||
it skips the auto sanity-gates (sample / wrong-episode / pack / not-an-upgrade) and
|
||||
files the file exactly where they said, replacing any worse copy. ffprobe is still
|
||||
used for the true resolution, but never to reject."""
|
||||
dl = dl or {}
|
||||
settings = organization.normalize(settings)
|
||||
override = override or {}
|
||||
scope = str(override.get("scope") or _scope_of(dl)).lower() if force else _scope_of(dl)
|
||||
name = basename_of(src_path)
|
||||
ext = ext_of(src_path)
|
||||
parsed = parse_release(dl.get("release_title") or name)
|
||||
ctx = _ctx(dl)
|
||||
if force: # the user told us what it is — let their identity win
|
||||
for k in ("title", "year", "season", "episode", "episode_title"):
|
||||
if override.get(k) is not None:
|
||||
ctx[k] = override.get(k)
|
||||
|
||||
if not is_video(src_path): # can't place a non-video, even on a forced import
|
||||
return _reject("Not a video file (%s)" % (ext or "no extension"))
|
||||
if not force:
|
||||
if is_sample(name, dl.get("size_bytes")):
|
||||
return _reject("Looks like a sample, not the feature")
|
||||
if scope not in ("movie", "episode"):
|
||||
return _reject("Season/complete packs need manual import")
|
||||
if scope == "episode":
|
||||
if ctx.get("season") is None or ctx.get("episode") is None:
|
||||
return _reject("Missing season/episode info")
|
||||
# If the release name itself names a DIFFERENT episode, don't mis-file it.
|
||||
if parsed.get("episode") is not None and (
|
||||
parsed.get("season") != ctx.get("season")
|
||||
or parsed.get("episode") != ctx.get("episode")):
|
||||
return _reject("Release is S%02dE%02d, not the episode requested"
|
||||
% (parsed.get("season") or 0, parsed.get("episode") or 0))
|
||||
else:
|
||||
if scope not in ("movie", "episode"):
|
||||
return _reject("Pick a movie or an episode to place this file")
|
||||
if scope == "episode" and (ctx.get("season") is None or ctx.get("episode") is None):
|
||||
return _reject("Pick a season and episode to place this file")
|
||||
|
||||
# ffprobe verification — best-effort; on a forced placement we use the real
|
||||
# resolution but never reject on it (the user has decided).
|
||||
if probe is not None:
|
||||
if not force:
|
||||
if not probe.get("ok"):
|
||||
return _reject("No readable video stream — corrupt or fake file")
|
||||
dur = probe.get("duration_sec") or 0
|
||||
floor = _RUNTIME_FLOOR.get(scope)
|
||||
if floor and 0 < dur < floor:
|
||||
return _reject("Runtime is only %d min — looks like a sample/clip, not the %s"
|
||||
% (int(dur // 60), scope))
|
||||
# Trust the FILE over the (often lying) scene name: real resolution always,
|
||||
# real codec only when the name didn't carry one.
|
||||
parsed = dict(parsed)
|
||||
if probe.get("resolution"):
|
||||
parsed["resolution"] = probe["resolution"]
|
||||
if probe.get("video_codec") and not parsed.get("codec"):
|
||||
parsed["codec"] = probe["video_codec"]
|
||||
|
||||
root = (override.get("target_dir") if force else None) or dl.get("target_dir") or ""
|
||||
if not root:
|
||||
return _reject("No library folder configured for this type")
|
||||
|
||||
media_id = override.get("media_id") if force else dl.get("media_id")
|
||||
quality = quality_full(parsed)
|
||||
fields = {
|
||||
"title": ctx.get("title"), "year": ctx.get("year"),
|
||||
"series": ctx.get("title"), "season": ctx.get("season"),
|
||||
"episode": ctx.get("episode"), "episode_title": ctx.get("episode_title"),
|
||||
"quality": quality, "resolution": parsed.get("resolution"),
|
||||
"source": parsed.get("source"), "codec": parsed.get("codec"),
|
||||
"tmdbid": media_id if scope == "movie" else None,
|
||||
"tvdbid": media_id if scope == "episode" else None,
|
||||
}
|
||||
dest = organization.render_path(scope, root, fields, settings, ext)
|
||||
# Where poster.jpg goes: the movie folder, or the SHOW root for an episode
|
||||
# (parent of the Season folder) — so it isn't dropped per-season.
|
||||
artwork_dir = dest["dir"] if scope == "movie" else os.path.dirname(dest["dir"])
|
||||
|
||||
existing = _existing_match(scope, dest["dir"], ctx, list_dir)
|
||||
if existing:
|
||||
# A forced placement replaces whatever's there (the user chose to put it here).
|
||||
if force:
|
||||
return {"action": "upgrade", "dest": dest, "quality_label": quality,
|
||||
"replace_path": os.path.join(dest["dir"], existing), "artwork_dir": artwork_dir}
|
||||
if not settings.get("replace_existing", True):
|
||||
return _reject("Already in the library (%s) — replace is turned off" % existing)
|
||||
new_score = quality_score(parsed)
|
||||
old_score = quality_score(parse_release(existing))
|
||||
if new_score > old_score:
|
||||
return {"action": "upgrade", "dest": dest, "quality_label": quality,
|
||||
"replace_path": os.path.join(dest["dir"], existing), "artwork_dir": artwork_dir}
|
||||
return _reject("Not an upgrade over the copy already in the library (%s)" % existing)
|
||||
|
||||
return {"action": "import", "dest": dest, "quality_label": quality, "artwork_dir": artwork_dir}
|
||||
|
||||
|
||||
def plan_subs(src_path: str, dest_path: str, list_dir: Callable) -> list:
|
||||
"""Sibling subtitle files to carry alongside the video, renamed to match the
|
||||
destination stem (preserving any language suffix, e.g. '.en.srt'). Returns a list
|
||||
of (src_abs, dest_abs) pairs. ``list_dir`` lists the SOURCE directory."""
|
||||
src_dir = os.path.dirname(src_path) or "."
|
||||
v_stem = os.path.splitext(basename_of(src_path))[0]
|
||||
d_stem = os.path.splitext(basename_of(dest_path))[0]
|
||||
d_dir = os.path.dirname(dest_path)
|
||||
out = []
|
||||
try:
|
||||
names = [str(n) for n in (list_dir(src_dir) or [])]
|
||||
except Exception: # noqa: BLE001
|
||||
return out
|
||||
for n in names:
|
||||
if ext_of(n) not in SUB_EXTS:
|
||||
continue
|
||||
stem, ext = os.path.splitext(n)
|
||||
if stem == v_stem:
|
||||
extra = "" # movie.srt → <dest>.srt
|
||||
elif stem.startswith(v_stem + "."):
|
||||
extra = stem[len(v_stem):] # movie.en.srt → <dest>.en.srt
|
||||
else:
|
||||
continue
|
||||
out.append((os.path.join(src_dir, n), os.path.join(d_dir, d_stem + extra + ext)))
|
||||
return out
|
||||
|
||||
|
||||
def run_import(dl: dict, src_path: str, *, fs: Any, prober: Callable | None = None,
|
||||
settings: dict | None = None, force: bool = False,
|
||||
override: dict | None = None) -> dict:
|
||||
"""Execute the import and return a DB patch dict for the download row.
|
||||
|
||||
``fs`` is an injected facade with: ``list_dir(dir)->iterable[name]``,
|
||||
``makedirs(dir)``, ``copy(src, dst)``, ``move(src, dst)``, ``remove(path)``.
|
||||
``prober(path)->mediainfo`` is an optional ffprobe hook (None = skip verification).
|
||||
``settings`` are the user's organisation settings (transfer mode, subtitle carry);
|
||||
None = defaults. ``force``/``override`` drive a MANUAL placement (see ``plan_import``).
|
||||
A reject becomes an ``import_failed`` row with ``dest_path`` pointing at the file's
|
||||
current (unplaced) location so the Import page can resolve it; a success becomes a
|
||||
``completed`` row with ``dest_path`` set to its final home."""
|
||||
settings = organization.normalize(settings)
|
||||
probe_info = None
|
||||
if prober is not None:
|
||||
try:
|
||||
probe_info = prober(src_path)
|
||||
except Exception: # noqa: BLE001 - a probe crash must not block the import
|
||||
probe_info = None
|
||||
plan = plan_import(dl, src_path, list_dir=fs.list_dir, probe=probe_info,
|
||||
settings=settings, force=force, override=override)
|
||||
if plan["action"] == "reject":
|
||||
# Leave the file where it is; remember WHERE so manual import can find it.
|
||||
return {"status": "import_failed", "progress": 100.0, "error": plan["reason"],
|
||||
"dest_path": src_path}
|
||||
|
||||
dest = plan["dest"]
|
||||
move_mode = settings.get("transfer_mode") == "move"
|
||||
try:
|
||||
fs.makedirs(dest["dir"])
|
||||
if move_mode:
|
||||
fs.move(src_path, dest["path"])
|
||||
else:
|
||||
fs.copy(src_path, dest["path"])
|
||||
if settings.get("carry_subtitles", True):
|
||||
for sub_src, sub_dst in plan_subs(src_path, dest["path"], fs.list_dir):
|
||||
try:
|
||||
fs.copy(sub_src, sub_dst)
|
||||
except Exception: # noqa: BLE001 - a subtitle that won't copy isn't fatal
|
||||
pass
|
||||
if plan["action"] == "upgrade" and plan.get("replace_path"):
|
||||
try:
|
||||
fs.remove(plan["replace_path"])
|
||||
except Exception: # noqa: BLE001 - failing to delete the old file isn't fatal
|
||||
pass
|
||||
# Copy mode reclaims the download copy UNLESS it's a torrent (keep seeding);
|
||||
# move mode already relocated it.
|
||||
if not move_mode and str(dl.get("source") or "").lower() != "torrent":
|
||||
try:
|
||||
fs.remove(src_path)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001 - any copy/mkdir failure → manual import
|
||||
return {"status": "import_failed", "progress": 100.0, "error": "Import failed: " + str(e),
|
||||
"dest_path": src_path}
|
||||
|
||||
return {"status": "completed", "progress": 100.0, "dest_path": dest["path"],
|
||||
"quality_label": plan.get("quality_label") or dl.get("quality_label")}
|
||||
|
||||
|
||||
class _RealFS:
|
||||
"""The production filesystem facade for ``run_import`` (os/shutil)."""
|
||||
|
||||
@staticmethod
|
||||
def list_dir(path):
|
||||
try:
|
||||
return os.listdir(str(path or ""))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def makedirs(path):
|
||||
os.makedirs(str(path or "."), exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def copy(src, dst):
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
@staticmethod
|
||||
def move(src, dst):
|
||||
shutil.move(src, dst)
|
||||
|
||||
@staticmethod
|
||||
def save_url(url, dst):
|
||||
import urllib.request
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "SoulSync"})
|
||||
with urllib.request.urlopen(req, timeout=20) as resp, open(dst, "wb") as f:
|
||||
shutil.copyfileobj(resp, f)
|
||||
|
||||
@staticmethod
|
||||
def write_text(path, content):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
@staticmethod
|
||||
def remove(path):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
def real_fs() -> _RealFS:
|
||||
return _RealFS()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"VIDEO_EXTS", "SUB_EXTS", "ext_of", "is_video", "is_sample", "quality_score",
|
||||
"plan_import", "plan_subs", "run_import", "real_fs",
|
||||
]
|
||||
165
core/video/library_paths.py
Normal file
165
core/video/library_paths.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"""Organize a finished video download into a Radarr/Sonarr-standard library path.
|
||||
|
||||
Given the SCOPE/intent of a grab (movie vs episode, plus title/year/season/episode)
|
||||
and the PARSED release quality, build the canonical folder + filename the file should
|
||||
land at:
|
||||
|
||||
<movies_root>/The Matrix (1999)/The Matrix (1999) Bluray-1080p.mkv
|
||||
<tv_root>/Breaking Bad/Season 01/Breaking Bad - S01E01 - Pilot WEBDL-1080p.mkv
|
||||
|
||||
These are the community-standard templates and also exactly what Plex/Jellyfin want
|
||||
for reliable identification (we organise on disk; the server just refreshes).
|
||||
|
||||
Pure (no filesystem, no DB) so it's unit-tested in isolation. Isolated — stdlib +
|
||||
the sibling ``download_pipeline.basename_of`` only; nothing from the music side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from core.video.download_pipeline import basename_of
|
||||
|
||||
# Characters illegal on Windows / awkward on most filesystems, plus control chars.
|
||||
_ILLEGAL = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
|
||||
_TRAILING_DOTSPACE = re.compile(r"[ .]+$")
|
||||
|
||||
# Parsed source token → the label used in the Radarr ``{Quality Full}`` tag.
|
||||
_SRC_LABEL = {
|
||||
"remux": "Remux", "bluray": "Bluray", "web-dl": "WEBDL",
|
||||
"webrip": "WEBRip", "hdtv": "HDTV", "dvd": "DVD",
|
||||
}
|
||||
|
||||
|
||||
def sanitize(name: Any) -> str:
|
||||
"""Filesystem-safe path COMPONENT: strip illegal chars, collapse whitespace, and
|
||||
trim trailing dots/spaces (Windows rejects those). Never contains a separator."""
|
||||
s = _ILLEGAL.sub("", str(name or ""))
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
s = _TRAILING_DOTSPACE.sub("", s)
|
||||
return s
|
||||
|
||||
|
||||
def _year_suffix(year: Any) -> str:
|
||||
"""' (1999)' for a plausible release year, else '' (keeps titles clean)."""
|
||||
try:
|
||||
y = int(year)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
return " (%d)" % y if 1870 <= y <= 2999 else ""
|
||||
|
||||
|
||||
def source_label(source: Any) -> str:
|
||||
"""The display label for a parsed source token ('bluray' → 'Bluray', 'web-dl' →
|
||||
'WEBDL'), '' when unknown. Used for the $source template token."""
|
||||
return _SRC_LABEL.get(str(source or "").strip().lower(), "")
|
||||
|
||||
|
||||
def quality_full(parsed: Any) -> str:
|
||||
"""The Radarr-style ``{Quality Full}`` tag, e.g. 'Bluray-1080p' / 'WEBDL-1080p' /
|
||||
'Remux-2160p'. '' when neither source nor resolution can be determined. A
|
||||
proper/repack is appended ('… Proper') so an upgrade reads at a glance."""
|
||||
parsed = parsed if isinstance(parsed, dict) else {}
|
||||
src = _SRC_LABEL.get(parsed.get("source"))
|
||||
res = parsed.get("resolution")
|
||||
if src and res:
|
||||
tag = src + "-" + res
|
||||
elif res:
|
||||
tag = res
|
||||
elif src:
|
||||
tag = src
|
||||
else:
|
||||
return ""
|
||||
if parsed.get("proper") or parsed.get("repack"):
|
||||
tag += " Proper"
|
||||
return tag
|
||||
|
||||
|
||||
def movie_folder(title: Any, year: Any) -> str:
|
||||
"""'The Matrix (1999)' — the per-movie folder name."""
|
||||
return (sanitize(title) or "Unknown") + _year_suffix(year)
|
||||
|
||||
|
||||
def movie_filename(title: Any, year: Any, quality: Any, ext: Any) -> str:
|
||||
"""'The Matrix (1999) Bluray-1080p.mkv' (quality tag omitted when unknown)."""
|
||||
stem = (sanitize(title) or "Unknown") + _year_suffix(year)
|
||||
q = sanitize(quality)
|
||||
if q:
|
||||
stem += " " + q
|
||||
return stem + _ext(ext)
|
||||
|
||||
|
||||
def show_folder(series: Any) -> str:
|
||||
"""'Breaking Bad' — the per-series folder name."""
|
||||
return sanitize(series) or "Unknown"
|
||||
|
||||
|
||||
def season_folder(season: Any) -> str:
|
||||
"""'Season 01' (or 'Specials' for season 0, Sonarr's convention)."""
|
||||
try:
|
||||
n = int(season)
|
||||
except (TypeError, ValueError):
|
||||
n = None
|
||||
if n == 0:
|
||||
return "Specials"
|
||||
return "Season %02d" % (n if n is not None else 0)
|
||||
|
||||
|
||||
def episode_filename(series: Any, season: Any, episode: Any, ep_title: Any,
|
||||
quality: Any, ext: Any) -> str:
|
||||
"""'Breaking Bad - S01E01 - Pilot WEBDL-1080p.mkv'. The ' - {title}' segment and
|
||||
the trailing quality tag are each omitted when unknown."""
|
||||
try:
|
||||
s = int(season)
|
||||
except (TypeError, ValueError):
|
||||
s = 0
|
||||
try:
|
||||
e = int(episode)
|
||||
except (TypeError, ValueError):
|
||||
e = 0
|
||||
stem = "%s - S%02dE%02d" % (sanitize(series) or "Unknown", s, e)
|
||||
t = sanitize(ep_title)
|
||||
if t:
|
||||
stem += " - " + t
|
||||
q = sanitize(quality)
|
||||
if q:
|
||||
stem += " " + q
|
||||
return stem + _ext(ext)
|
||||
|
||||
|
||||
def _ext(ext: Any) -> str:
|
||||
e = str(ext or "").strip().lower()
|
||||
if not e:
|
||||
return ""
|
||||
return e if e.startswith(".") else "." + e
|
||||
|
||||
|
||||
def plan_path(scope: Any, root: Any, ctx: dict, quality: Any, ext: Any) -> dict:
|
||||
"""Resolve the canonical destination for a finished download.
|
||||
|
||||
``ctx`` = {title, year, season, episode, episode_title}. Returns
|
||||
``{"dir": <abs folder>, "filename": <name>, "path": <abs file path>}``. For an
|
||||
unsupported scope it falls back to a flat drop (root + sanitized basename) so a
|
||||
file is never lost — the caller gates scope before relying on this."""
|
||||
ctx = ctx if isinstance(ctx, dict) else {}
|
||||
root = str(root or "")
|
||||
sc = str(scope or "").lower()
|
||||
if sc == "movie":
|
||||
d = os.path.join(root, movie_folder(ctx.get("title"), ctx.get("year")))
|
||||
fn = movie_filename(ctx.get("title"), ctx.get("year"), quality, ext)
|
||||
elif sc == "episode":
|
||||
d = os.path.join(root, show_folder(ctx.get("title")), season_folder(ctx.get("season")))
|
||||
fn = episode_filename(ctx.get("title"), ctx.get("season"), ctx.get("episode"),
|
||||
ctx.get("episode_title"), quality, ext)
|
||||
else:
|
||||
d = root
|
||||
fn = sanitize(basename_of(ctx.get("src") or "")) or "download"
|
||||
return {"dir": d, "filename": fn, "path": os.path.join(d, fn)}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"sanitize", "source_label", "quality_full", "movie_folder", "movie_filename",
|
||||
"show_folder", "season_folder", "episode_filename", "plan_path",
|
||||
]
|
||||
137
core/video/mediainfo.py
Normal file
137
core/video/mediainfo.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Probe a finished video file with ffprobe for its TRUE media info.
|
||||
|
||||
We otherwise trust the release NAME for resolution/quality, and names lie: 720p
|
||||
upscales labelled 1080p, trailers/samples labelled as the feature, broken muxes.
|
||||
ffprobe reads the real container — duration, dimensions → resolution, codecs — so the
|
||||
importer can tag the file by its actual quality and reject corrupt / too-short junk.
|
||||
|
||||
The parsing (``parse_ffprobe`` / ``resolution_from_dimensions``) is pure and unit-tested
|
||||
on canned JSON; the subprocess runner is injected, so nothing here needs ffmpeg to be
|
||||
tested. ffmpeg is OPTIONAL — when ffprobe isn't installed, or it errors, ``probe``
|
||||
returns None and the caller falls back to the scene name.
|
||||
|
||||
Three outcomes, deliberately distinct:
|
||||
- None → couldn't verify (ffprobe missing / crashed / timed out) → skip
|
||||
- {"ok": False, …} → ffprobe ran and found NO video stream → corrupt / fake
|
||||
- {"ok": True, …} → real media info to trust over the name
|
||||
|
||||
Isolated: stdlib only; no music imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Any, Callable
|
||||
|
||||
_FFPROBE = "ffprobe"
|
||||
|
||||
|
||||
def _int(v: Any) -> int:
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _float(v: Any) -> float:
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def resolution_from_dimensions(width: Any, height: Any) -> str | None:
|
||||
"""Bucket real pixel dimensions into a resolution label. Uses the LARGER axis so
|
||||
a letterboxed 1920x800 movie reads as 1080p (not 720p by its short side)."""
|
||||
ref = max(_int(width), _int(height))
|
||||
if ref <= 0:
|
||||
return None
|
||||
if ref >= 3000:
|
||||
return "2160p"
|
||||
if ref >= 1700:
|
||||
return "1080p"
|
||||
if ref >= 1100:
|
||||
return "720p"
|
||||
return "480p"
|
||||
|
||||
|
||||
def _norm_codec(name: Any) -> str | None:
|
||||
s = str(name or "").strip().lower()
|
||||
if not s:
|
||||
return None
|
||||
if s in ("hevc", "h265", "x265"):
|
||||
return "hevc"
|
||||
if s in ("h264", "avc", "x264"):
|
||||
return "x264"
|
||||
if s == "av1":
|
||||
return "av1"
|
||||
return s
|
||||
|
||||
|
||||
def parse_ffprobe(data: Any) -> dict:
|
||||
"""Parse ffprobe's ``-show_format -show_streams`` JSON into the fields we use.
|
||||
``ok`` is True only when a video stream is present (else: corrupt / not a video)."""
|
||||
data = data if isinstance(data, dict) else {}
|
||||
streams = data.get("streams") or []
|
||||
fmt = data.get("format") or {}
|
||||
video = next((s for s in streams if s.get("codec_type") == "video"), None)
|
||||
audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
|
||||
duration = _float(fmt.get("duration")) or _float((video or {}).get("duration"))
|
||||
width = (video or {}).get("width")
|
||||
height = (video or {}).get("height")
|
||||
return {
|
||||
"ok": video is not None,
|
||||
"duration_sec": duration,
|
||||
"width": _int(width),
|
||||
"height": _int(height),
|
||||
"resolution": resolution_from_dimensions(width, height) if video else None,
|
||||
"video_codec": _norm_codec((video or {}).get("codec_name")),
|
||||
"audio_codec": str((audio or {}).get("codec_name") or "") or None,
|
||||
}
|
||||
|
||||
|
||||
def ffprobe_available() -> bool:
|
||||
return shutil.which(_FFPROBE) is not None
|
||||
|
||||
|
||||
def _default_runner(path: str) -> str | None:
|
||||
"""Run ffprobe and return its JSON stdout, or None on any failure (so a transient
|
||||
ffprobe error degrades to 'unverified', never to a false 'corrupt')."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[_FFPROBE, "-v", "quiet", "-print_format", "json",
|
||||
"-show_format", "-show_streams", str(path)],
|
||||
capture_output=True, text=True, timeout=120, check=False,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - missing binary / timeout / OS error → unverified
|
||||
return None
|
||||
if proc.returncode != 0 or not (proc.stdout or "").strip():
|
||||
return None
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def probe(path: Any, runner: Callable | None = None) -> dict | None:
|
||||
"""Probe ``path`` and return parsed media info, or None when it can't be verified.
|
||||
``runner(path)->json_str|None`` is injected (real ffprobe in prod, canned in tests).
|
||||
When no runner is given and ffprobe isn't installed, returns None (skip verify)."""
|
||||
use = runner if runner is not None else (_default_runner if ffprobe_available() else None)
|
||||
if use is None:
|
||||
return None
|
||||
try:
|
||||
raw = use(path)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return parse_ffprobe(data)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"resolution_from_dimensions", "parse_ffprobe", "ffprobe_available", "probe",
|
||||
]
|
||||
213
core/video/organization.py
Normal file
213
core/video/organization.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""Video library-organisation settings: naming templates + post-process toggles.
|
||||
|
||||
Mirrors the MUSIC side's file-organisation standard — editable ``$token`` path
|
||||
templates with per-component sanitisation and dangling-separator cleanup — but for
|
||||
video's movie/episode shape, plus the optional post-process behaviours the user can
|
||||
turn on and off.
|
||||
|
||||
Settings (persisted as JSON in video.db ``video_settings['organization']``):
|
||||
- ``movie_template`` : path template for movies (folders via '/', last = file)
|
||||
- ``episode_template`` : path template for episodes
|
||||
- ``verify_with_ffprobe`` : probe the real file (true quality + reject junk)
|
||||
- ``replace_existing`` : upgrade-replace a worse copy already in the library
|
||||
- ``transfer_mode`` : 'copy' (reclaim source unless torrent) | 'move'
|
||||
- ``carry_subtitles`` : bring sibling .srt/.ass alongside the video
|
||||
|
||||
Template tokens
|
||||
Movies: $title $titlefirst $year $quality $resolution $source $codec $edition
|
||||
$tmdbid $imdbid
|
||||
Episodes: $series $season $seasonraw $episode $episodetitle $year $quality
|
||||
$resolution $source $codec $tvdbid
|
||||
($season/$episode are zero-padded to 2; $seasonraw is the bare number.)
|
||||
|
||||
Pure data + a pure renderer (no DB, no FS) so it's unit-tested in isolation. Isolated —
|
||||
stdlib + sibling video ``library_paths`` only; nothing from the music side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from core.video.library_paths import sanitize, source_label
|
||||
|
||||
DEFAULTS = {
|
||||
"version": 1,
|
||||
"movie_template": "$title ($year)/$title ($year) $quality",
|
||||
"episode_template": "$series/Season $season/$series - S$seasonE$episode - $episodetitle $quality",
|
||||
"verify_with_ffprobe": True,
|
||||
"replace_existing": True,
|
||||
"transfer_mode": "copy",
|
||||
"carry_subtitles": True,
|
||||
"save_artwork": False,
|
||||
"write_nfo": False,
|
||||
"download_subtitles": False,
|
||||
"subtitle_langs": "en",
|
||||
}
|
||||
|
||||
_TRANSFER_MODES = ("copy", "move")
|
||||
|
||||
|
||||
def default_settings() -> dict:
|
||||
return dict(DEFAULTS)
|
||||
|
||||
|
||||
def normalize(raw: Any) -> dict:
|
||||
"""Coerce stored/posted settings to a valid shape, filling gaps from the default.
|
||||
Blank templates fall back to the default; never raises."""
|
||||
d = default_settings()
|
||||
if not isinstance(raw, dict):
|
||||
return d
|
||||
for key in ("movie_template", "episode_template"):
|
||||
v = raw.get(key)
|
||||
if isinstance(v, str) and v.strip():
|
||||
d[key] = v.strip()
|
||||
for key in ("verify_with_ffprobe", "replace_existing", "carry_subtitles",
|
||||
"save_artwork", "write_nfo", "download_subtitles"):
|
||||
if key in raw:
|
||||
d[key] = bool(raw.get(key))
|
||||
tm = str(raw.get("transfer_mode") or "").strip().lower()
|
||||
if tm in _TRANSFER_MODES:
|
||||
d["transfer_mode"] = tm
|
||||
if "subtitle_langs" in raw:
|
||||
from core.video.subtitles import parse_langs
|
||||
d["subtitle_langs"] = ",".join(parse_langs(raw.get("subtitle_langs")))
|
||||
return d
|
||||
|
||||
|
||||
def load(db) -> dict:
|
||||
raw = db.get_setting("organization")
|
||||
if raw:
|
||||
try:
|
||||
return normalize(json.loads(raw))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return default_settings()
|
||||
|
||||
|
||||
def save(db, raw: Any) -> dict:
|
||||
s = normalize(raw)
|
||||
db.set_setting("organization", json.dumps(s))
|
||||
return s
|
||||
|
||||
|
||||
# ── the template engine (the music $token standard, video tokens) ─────────────
|
||||
def _str(v: Any) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
return str(v)
|
||||
|
||||
|
||||
def _pad2(v: Any) -> str:
|
||||
try:
|
||||
return "%02d" % int(v)
|
||||
except (TypeError, ValueError):
|
||||
return _str(v)
|
||||
|
||||
|
||||
def _plausible_year(v: Any) -> bool:
|
||||
try:
|
||||
return 1870 <= int(v) <= 2999
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _ext(ext: Any) -> str:
|
||||
e = str(ext or "").strip().lower()
|
||||
if not e:
|
||||
return ""
|
||||
return e if e.startswith(".") else "." + e
|
||||
|
||||
|
||||
def _movie_values(f: dict) -> dict:
|
||||
title = f.get("title") or "Unknown"
|
||||
return {
|
||||
"title": title,
|
||||
"titlefirst": (str(title)[:1] or "U").upper(),
|
||||
"year": _str(f.get("year")) if _plausible_year(f.get("year")) else "",
|
||||
"quality": _str(f.get("quality")),
|
||||
"resolution": _str(f.get("resolution")),
|
||||
"source": source_label(f.get("source")),
|
||||
"codec": _str(f.get("codec")).upper(),
|
||||
"edition": _str(f.get("edition")),
|
||||
"tmdbid": _str(f.get("tmdbid")),
|
||||
"imdbid": _str(f.get("imdbid")),
|
||||
}
|
||||
|
||||
|
||||
def _episode_values(f: dict) -> dict:
|
||||
series = f.get("series") or f.get("title") or "Unknown"
|
||||
return {
|
||||
"series": series,
|
||||
"season": _pad2(f.get("season")),
|
||||
"seasonraw": _str(f.get("season")),
|
||||
"episode": _pad2(f.get("episode")),
|
||||
"episodetitle": _str(f.get("episode_title")),
|
||||
"year": _str(f.get("year")) if _plausible_year(f.get("year")) else "",
|
||||
"quality": _str(f.get("quality")),
|
||||
"resolution": _str(f.get("resolution")),
|
||||
"source": source_label(f.get("source")),
|
||||
"codec": _str(f.get("codec")).upper(),
|
||||
"tvdbid": _str(f.get("tvdbid")),
|
||||
}
|
||||
|
||||
|
||||
def render_template(template: Any, values: dict) -> str:
|
||||
"""Substitute ``$token`` / ``${token}`` from ``values`` into ``template``. Each
|
||||
value is path-sanitised first (so a title with '/' can't spawn a folder), and
|
||||
tokens are replaced longest-name-first ($episodetitle before $episode)."""
|
||||
clean = {k: sanitize(v) for k, v in (values or {}).items()}
|
||||
out = str(template or "")
|
||||
for tok in sorted(clean, key=len, reverse=True):
|
||||
out = out.replace("${" + tok + "}", clean[tok])
|
||||
for tok in sorted(clean, key=len, reverse=True):
|
||||
out = out.replace("$" + tok, clean[tok])
|
||||
return out
|
||||
|
||||
|
||||
def _tidy_component(part: str) -> str:
|
||||
"""Clean one path segment: drop a ' - ' left dangling by an empty token, remove
|
||||
empty ()/[] left by an empty $year, collapse whitespace, trim stray dashes and
|
||||
Windows-hostile trailing dots/spaces."""
|
||||
p = re.sub(r"\s+-\s+(?=(\s|$))", " ", part) # ' - ' before an empty token
|
||||
p = re.sub(r"\(\s*\)", "", p) # empty ( ) from a missing $year
|
||||
p = re.sub(r"\[\s*\]", "", p)
|
||||
p = re.sub(r"\s+", " ", p).strip()
|
||||
p = p.strip("-").strip()
|
||||
return p.rstrip(". ")
|
||||
|
||||
|
||||
def render_path(scope: Any, root: Any, fields: dict, settings: Any, ext: Any) -> dict:
|
||||
"""Render the destination for a finished download from the user's templates.
|
||||
Returns ``{"dir", "filename", "path"}`` (same shape as ``library_paths.plan_path``).
|
||||
An unsupported scope falls back to a flat drop so a file is never lost."""
|
||||
settings = settings if isinstance(settings, dict) else {}
|
||||
fields = fields if isinstance(fields, dict) else {}
|
||||
root = str(root or "")
|
||||
sc = str(scope or "").lower()
|
||||
|
||||
if sc == "movie":
|
||||
tmpl = settings.get("movie_template") or DEFAULTS["movie_template"]
|
||||
values = _movie_values(fields)
|
||||
elif sc == "episode":
|
||||
tmpl = settings.get("episode_template") or DEFAULTS["episode_template"]
|
||||
values = _episode_values(fields)
|
||||
else:
|
||||
base = (sanitize(fields.get("title")) or "download") + _ext(ext)
|
||||
return {"dir": root, "filename": base, "path": os.path.join(root, base)}
|
||||
|
||||
rendered = render_template(tmpl, values)
|
||||
parts = [p for p in (_tidy_component(seg) for seg in rendered.split("/")) if p]
|
||||
if not parts:
|
||||
parts = ["download"]
|
||||
d = os.path.join(root, *parts[:-1]) if len(parts) > 1 else root
|
||||
filename = parts[-1] + _ext(ext)
|
||||
return {"dir": d, "filename": filename, "path": os.path.join(d, filename)}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULTS", "default_settings", "normalize", "load", "save",
|
||||
"render_template", "render_path",
|
||||
]
|
||||
229
core/video/sidecars.py
Normal file
229
core/video/sidecars.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Write Kodi/Jellyfin-style sidecars next to imported video — NFO metadata + the full
|
||||
artwork set — so the library is self-describing on disk (server-agnostic + portable).
|
||||
|
||||
The metadata comes from the on-demand TMDB detail fetch (``engine.tmdb_detail``), passed
|
||||
in as ``meta``. This module is pure — NFO XML building + a write plan — with the
|
||||
filesystem injected, so it's unit-tested without disk or network. Best-effort BY
|
||||
CONTRACT: the caller treats every failure as non-fatal (a missing poster or a flaky
|
||||
fetch never breaks an import).
|
||||
|
||||
Layout written:
|
||||
Movie folder: movie.nfo, poster.jpg, fanart.jpg, clearlogo.png
|
||||
Show root: tvshow.nfo, poster.jpg, fanart.jpg, clearlogo.png, seasonNN-poster.jpg
|
||||
|
||||
Isolated: stdlib only; no music imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from xml.sax.saxutils import escape as _xesc
|
||||
|
||||
_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
||||
|
||||
|
||||
def _t(tag: str, value: Any) -> str:
|
||||
"""A single XML element, or '' when the value is empty (so absent fields are
|
||||
simply omitted rather than written blank)."""
|
||||
if value is None:
|
||||
return ""
|
||||
s = str(value).strip()
|
||||
if not s:
|
||||
return ""
|
||||
return " <%s>%s</%s>\n" % (tag, _xesc(s), tag)
|
||||
|
||||
|
||||
def _year(meta: dict) -> str:
|
||||
y = meta.get("year")
|
||||
return str(y) if y else ""
|
||||
|
||||
|
||||
def _uniqueids(meta: dict, *, tmdb_default: bool) -> str:
|
||||
out = ""
|
||||
tmdb = meta.get("tmdb_id")
|
||||
if tmdb:
|
||||
out += ' <uniqueid type="tmdb"%s>%s</uniqueid>\n' % (
|
||||
' default="true"' if tmdb_default else "", _xesc(str(tmdb)))
|
||||
if meta.get("tvdb_id"):
|
||||
out += ' <uniqueid type="tvdb"%s>%s</uniqueid>\n' % (
|
||||
' default="true"' if not tmdb_default else "", _xesc(str(meta["tvdb_id"])))
|
||||
if meta.get("imdb_id"):
|
||||
out += ' <uniqueid type="imdb">%s</uniqueid>\n' % _xesc(str(meta["imdb_id"]))
|
||||
return out
|
||||
|
||||
|
||||
def _genres(meta: dict) -> str:
|
||||
return "".join(_t("genre", g) for g in (meta.get("genres") or []) if g)
|
||||
|
||||
|
||||
def _actors(meta: dict, limit: int = 20) -> str:
|
||||
out = ""
|
||||
for i, c in enumerate((meta.get("cast") or [])[:limit]):
|
||||
if not isinstance(c, dict) or not c.get("name"):
|
||||
continue
|
||||
out += " <actor>\n <name>%s</name>\n" % _xesc(str(c["name"]))
|
||||
if c.get("character"):
|
||||
out += " <role>%s</role>\n" % _xesc(str(c["character"]))
|
||||
out += " <order>%d</order>\n </actor>\n" % i
|
||||
return out
|
||||
|
||||
|
||||
def _artwork_tags(meta: dict) -> str:
|
||||
out = ""
|
||||
if meta.get("poster_url"):
|
||||
out += ' <thumb aspect="poster">%s</thumb>\n' % _xesc(str(meta["poster_url"]))
|
||||
if meta.get("logo"):
|
||||
out += ' <thumb aspect="clearlogo">%s</thumb>\n' % _xesc(str(meta["logo"]))
|
||||
if meta.get("backdrop_url"):
|
||||
out += " <fanart>\n <thumb>%s</thumb>\n </fanart>\n" % _xesc(str(meta["backdrop_url"]))
|
||||
return out
|
||||
|
||||
|
||||
def nfo_movie(meta: dict) -> str:
|
||||
"""A Kodi/Jellyfin-compatible ``movie.nfo`` from a TMDB detail dict."""
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
body = (
|
||||
_t("title", meta.get("title"))
|
||||
+ _t("originaltitle", meta.get("original_title"))
|
||||
+ _t("year", _year(meta))
|
||||
+ _t("plot", meta.get("overview"))
|
||||
+ _t("outline", meta.get("overview"))
|
||||
+ _t("tagline", meta.get("tagline"))
|
||||
+ _t("runtime", meta.get("runtime_minutes"))
|
||||
+ _t("mpaa", meta.get("content_rating"))
|
||||
+ _t("studio", meta.get("studio"))
|
||||
+ _t("premiered", meta.get("release_date"))
|
||||
+ _t("status", meta.get("status"))
|
||||
+ _t("rating", meta.get("rating"))
|
||||
+ _genres(meta)
|
||||
+ _uniqueids(meta, tmdb_default=True)
|
||||
+ _artwork_tags(meta)
|
||||
+ _actors(meta)
|
||||
)
|
||||
return _HEAD + "<movie>\n" + body + "</movie>\n"
|
||||
|
||||
|
||||
def nfo_tvshow(meta: dict) -> str:
|
||||
"""A Kodi/Jellyfin-compatible ``tvshow.nfo`` from a TMDB detail dict."""
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
body = (
|
||||
_t("title", meta.get("title"))
|
||||
+ _t("year", _year(meta))
|
||||
+ _t("plot", meta.get("overview"))
|
||||
+ _t("outline", meta.get("overview"))
|
||||
+ _t("tagline", meta.get("tagline"))
|
||||
+ _t("runtime", meta.get("runtime_minutes"))
|
||||
+ _t("mpaa", meta.get("content_rating"))
|
||||
+ _t("studio", meta.get("network"))
|
||||
+ _t("premiered", meta.get("first_air_date"))
|
||||
+ _t("status", meta.get("status"))
|
||||
+ _t("rating", meta.get("rating"))
|
||||
+ _genres(meta)
|
||||
+ _uniqueids(meta, tmdb_default=True)
|
||||
+ _artwork_tags(meta)
|
||||
+ _actors(meta)
|
||||
)
|
||||
return _HEAD + "<tvshow>\n" + body + "</tvshow>\n"
|
||||
|
||||
|
||||
def _season_art(meta: dict) -> list:
|
||||
"""(url, filename) pairs for season posters — seasonNN-poster.jpg (Specials → 00)."""
|
||||
out = []
|
||||
for s in (meta.get("_seasons") or []):
|
||||
if not isinstance(s, dict) or not s.get("poster_url"):
|
||||
continue
|
||||
try:
|
||||
n = int(s.get("season_number"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
out.append((s["poster_url"], "season%02d-poster.jpg" % n))
|
||||
return out
|
||||
|
||||
|
||||
def plan_sidecars(scope: Any, meta: dict, settings: dict) -> dict:
|
||||
"""Decide what to write for one imported item. Returns
|
||||
``{"nfo": (filename, content) | None, "art": [(url, filename), ...]}`` — gated by
|
||||
the ``write_nfo`` / ``save_artwork`` settings. Pure."""
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
settings = settings if isinstance(settings, dict) else {}
|
||||
sc = str(scope or "").lower()
|
||||
nfo = None
|
||||
art = []
|
||||
|
||||
if settings.get("write_nfo"):
|
||||
if sc == "movie":
|
||||
nfo = ("movie.nfo", nfo_movie(meta))
|
||||
elif sc == "episode":
|
||||
nfo = ("tvshow.nfo", nfo_tvshow(meta))
|
||||
|
||||
if settings.get("save_artwork"):
|
||||
if meta.get("poster_url"):
|
||||
art.append((meta["poster_url"], "poster.jpg"))
|
||||
if meta.get("backdrop_url"):
|
||||
art.append((meta["backdrop_url"], "fanart.jpg"))
|
||||
if meta.get("logo"):
|
||||
art.append((meta["logo"], "clearlogo.png"))
|
||||
if sc == "episode":
|
||||
art.extend(_season_art(meta))
|
||||
|
||||
return {"nfo": nfo, "art": art}
|
||||
|
||||
|
||||
def write(folder: str, scope: Any, meta: dict, settings: dict, fs: Any) -> None:
|
||||
"""Write the planned sidecars into ``folder`` via the injected ``fs``
|
||||
(``list_dir``, ``makedirs``, ``write_text(path, str)``, ``save_url(url, path)``).
|
||||
Idempotent: skips any file already present so upgrades/re-imports don't refetch.
|
||||
Best-effort: each file is independent and a failure is swallowed."""
|
||||
import os
|
||||
plan = plan_sidecars(scope, meta, settings)
|
||||
if not plan["nfo"] and not plan["art"]:
|
||||
return
|
||||
try:
|
||||
existing = {str(n).lower() for n in (fs.list_dir(folder) or [])}
|
||||
except Exception: # noqa: BLE001
|
||||
existing = set()
|
||||
try:
|
||||
fs.makedirs(folder)
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
|
||||
if plan["nfo"]:
|
||||
name, content = plan["nfo"]
|
||||
if name.lower() not in existing:
|
||||
try:
|
||||
fs.write_text(os.path.join(folder, name), content)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
for url, name in plan["art"]:
|
||||
if name.lower() in existing:
|
||||
continue
|
||||
try:
|
||||
fs.save_url(url, os.path.join(folder, name))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def write_for(dest_path: str, scope: Any, poster_url: Any, detail: Any,
|
||||
settings: dict, fs: Any) -> None:
|
||||
"""Resolve the sidecar folder from a finished file's path and write into it. The
|
||||
folder is the movie folder (parent of the file) or, for an episode, the SHOW root
|
||||
(parent of the Season folder) so show-level art/NFO land once. ``poster_url`` is the
|
||||
baseline from the download row; ``detail`` (the TMDB fetch, or None) fills the rest —
|
||||
so a poster still lands even when the detail fetch is unavailable."""
|
||||
import os
|
||||
sc = str(scope or "").lower()
|
||||
if sc == "movie":
|
||||
folder = os.path.dirname(dest_path)
|
||||
elif sc == "episode":
|
||||
folder = os.path.dirname(os.path.dirname(dest_path)) # show root, above Season NN
|
||||
else:
|
||||
return
|
||||
if not folder:
|
||||
return
|
||||
meta = {"poster_url": poster_url} if poster_url else {}
|
||||
if isinstance(detail, dict):
|
||||
meta.update(detail)
|
||||
write(folder, sc, meta, settings, fs)
|
||||
|
||||
|
||||
__all__ = ["nfo_movie", "nfo_tvshow", "plan_sidecars", "write", "write_for"]
|
||||
153
core/video/subtitles.py
Normal file
153
core/video/subtitles.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Download external subtitle .srt files from OpenSubtitles and drop them next to the
|
||||
imported video as ``<video stem>.<lang>.srt``.
|
||||
|
||||
The enrichment worker only records subtitle-language AVAILABILITY; this fetches the
|
||||
actual file: search → pick the most-downloaded subtitle for the language → request the
|
||||
(time-limited) download link → save it. Parsing (``parse_langs`` / ``pick_best_file``)
|
||||
is pure; the HTTP and filesystem are injected, so it's unit-tested without network or
|
||||
disk. Best-effort BY CONTRACT — OpenSubtitles' free tier is daily-quota-limited, so a
|
||||
miss is normal and never breaks an import.
|
||||
|
||||
Isolated: stdlib only; no music imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
|
||||
BASE = "https://api.opensubtitles.com/api/v1"
|
||||
_UA = "SoulSync v1.0"
|
||||
|
||||
|
||||
def parse_langs(raw: Any) -> list:
|
||||
"""'en, es ; fr' → ['en','es','fr'] (lower-cased, de-duped). Defaults to ['en']."""
|
||||
out = []
|
||||
for tok in re.split(r"[,\s;]+", str(raw or "")):
|
||||
t = tok.strip().lower()
|
||||
if t and t not in out:
|
||||
out.append(t)
|
||||
return out or ["en"]
|
||||
|
||||
|
||||
def pick_best_file(search_json: Any, lang: str) -> Any:
|
||||
"""The ``file_id`` of the most-downloaded subtitle for ``lang`` in a /subtitles
|
||||
response, or None. Pure."""
|
||||
if not isinstance(search_json, dict):
|
||||
return None
|
||||
best, best_dl = None, -1
|
||||
want = str(lang or "").lower()
|
||||
for row in (search_json.get("data") or []):
|
||||
attrs = row.get("attributes") or {}
|
||||
if str(attrs.get("language") or "").lower() != want:
|
||||
continue
|
||||
files = attrs.get("files") or []
|
||||
if not files or files[0].get("file_id") is None:
|
||||
continue
|
||||
dl = attrs.get("download_count") or 0
|
||||
if dl > best_dl:
|
||||
best, best_dl = files[0]["file_id"], dl
|
||||
return best
|
||||
|
||||
|
||||
def srt_name(video_path: Any, lang: str) -> str:
|
||||
"""'<video stem>.<lang>.srt' (no directory)."""
|
||||
stem = os.path.splitext(os.path.basename(str(video_path or "")))[0]
|
||||
return "%s.%s.srt" % (stem, lang)
|
||||
|
||||
|
||||
def search_params(identity: dict, lang: str) -> dict | None:
|
||||
"""OpenSubtitles /subtitles query for a title identity. For an episode the show's
|
||||
id is used with season/episode; for a movie, the imdb/tmdb id. None if unidentified."""
|
||||
identity = identity if isinstance(identity, dict) else {}
|
||||
params = {"languages": lang}
|
||||
if identity.get("season") is not None and identity.get("tmdb_id"):
|
||||
params["parent_tmdb_id"] = identity["tmdb_id"]
|
||||
params["season_number"] = identity["season"]
|
||||
params["episode_number"] = identity.get("episode")
|
||||
return params
|
||||
if identity.get("imdb_id"):
|
||||
params["imdb_id"] = re.sub(r"^tt", "", str(identity["imdb_id"]).lower())
|
||||
return params
|
||||
if identity.get("tmdb_id"):
|
||||
params["tmdb_id"] = identity["tmdb_id"]
|
||||
return params
|
||||
return None
|
||||
|
||||
|
||||
# ── real HTTP fetcher (injected into write_subtitles) ─────────────────────────
|
||||
def _headers(key: str) -> dict:
|
||||
return {"Api-Key": key, "User-Agent": _UA, "Accept": "application/json"}
|
||||
|
||||
|
||||
def _get_json(url: str, params: dict, headers: dict) -> Any:
|
||||
req = urllib.request.Request(url + "?" + urllib.parse.urlencode(params), headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _post_json(url: str, body: dict, headers: dict) -> Any:
|
||||
h = dict(headers)
|
||||
h["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=json.dumps(body).encode("utf-8"), headers=h, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _get_text(url: str) -> str:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": _UA})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return r.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def opensubtitles_fetcher(api_key: Any) -> Callable | None:
|
||||
"""Return ``fetch(identity, lang) -> srt_text | None`` backed by OpenSubtitles, or
|
||||
None when there's no key. ``identity`` = {imdb_id?, tmdb_id?, season?, episode?}."""
|
||||
key = str(api_key or "").strip()
|
||||
if not key:
|
||||
return None
|
||||
|
||||
def fetch(identity, lang):
|
||||
params = search_params(identity, lang)
|
||||
if params is None:
|
||||
return None
|
||||
found = _get_json(BASE + "/subtitles", params, _headers(key))
|
||||
file_id = pick_best_file(found, lang)
|
||||
if file_id is None:
|
||||
return None
|
||||
dl = _post_json(BASE + "/download", {"file_id": file_id}, _headers(key))
|
||||
link = (dl or {}).get("link")
|
||||
return _get_text(link) if link else None
|
||||
|
||||
return fetch
|
||||
|
||||
|
||||
def write_subtitles(video_path: str, langs: list, identity: dict, fetch: Callable, fs: Any) -> None:
|
||||
"""For each language not already present as ``<stem>.<lang>.srt`` next to the video,
|
||||
fetch and write it via the injected ``fetch`` + ``fs`` (``list_dir``, ``write_text``).
|
||||
Idempotent + best-effort."""
|
||||
if not fetch:
|
||||
return
|
||||
folder = os.path.dirname(str(video_path or ""))
|
||||
try:
|
||||
existing = {str(n).lower() for n in (fs.list_dir(folder) or [])}
|
||||
except Exception: # noqa: BLE001
|
||||
existing = set()
|
||||
for lang in (langs or []):
|
||||
name = srt_name(video_path, lang)
|
||||
if name.lower() in existing:
|
||||
continue
|
||||
try:
|
||||
text = fetch(identity, lang)
|
||||
if text:
|
||||
fs.write_text(os.path.join(folder, name), text)
|
||||
except Exception: # noqa: BLE001 - a quota miss / network blip is expected, never fatal
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["parse_langs", "pick_best_file", "srt_name", "search_params",
|
||||
"opensubtitles_fetcher", "write_subtitles"]
|
||||
|
|
@ -1261,6 +1261,33 @@ class VideoDatabase:
|
|||
finally:
|
||||
conn.close()
|
||||
|
||||
def media_tmdb_id(self, kind: str, media_id) -> tuple:
|
||||
"""(tmdb_id, imdb_id) for a library movie/show row — used to resolve sidecar /
|
||||
subtitle metadata for an owned re-grab (whose media_id is the library id, not a
|
||||
TMDB id). (None, None) if the row is gone."""
|
||||
table = "movies" if str(kind or "").lower() == "movie" else "shows"
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT tmdb_id, imdb_id FROM %s WHERE id = ?" % table, (media_id,)
|
||||
).fetchone()
|
||||
return (row["tmdb_id"], row["imdb_id"]) if row else (None, None)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_import_failed_video_downloads(self) -> list:
|
||||
"""Downloads that finished but couldn't be auto-placed (sample / wrong episode /
|
||||
not-an-upgrade / corrupt / pack / parse fail). Their file is still on disk at
|
||||
``dest_path`` — the Import page surfaces these for manual placement."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM video_downloads WHERE status = 'import_failed' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def update_video_download(self, dl_id: int, **fields) -> None:
|
||||
"""Patch a download row; ``updated_at`` is always bumped."""
|
||||
if not fields:
|
||||
|
|
@ -1278,7 +1305,7 @@ class VideoDatabase:
|
|||
def clear_finished_video_downloads(self) -> int:
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM video_downloads WHERE status IN ('completed', 'failed', 'cancelled')")
|
||||
cur = conn.execute("DELETE FROM video_downloads WHERE status IN ('completed', 'failed', 'cancelled', 'import_failed')")
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -23,11 +23,10 @@ _CSS = (_ROOT / "webui" / "static" / "video" / "video-side.css").read_text(encod
|
|||
def test_each_source_has_manual_and_auto_buttons():
|
||||
assert 'data-vdl-search="' in _VIEW # Manual (pick yourself)
|
||||
assert 'data-vdl-auto="' in _VIEW # Auto (best pick)
|
||||
# Renamed for clarity: Manual vs Auto (icon + label now split into spans).
|
||||
assert '>Manual<' in _VIEW and '>Auto<' in _VIEW
|
||||
# The harsh lightning was redesigned out in favour of a monochrome sparkle.
|
||||
assert '⚡' not in _VIEW
|
||||
assert 'vdl-btn-ic--auto' in _VIEW
|
||||
# Flat/brutalist: plain UPPERCASE labels, no icon spans, no decorative glyphs.
|
||||
assert '>MANUAL<' in _VIEW and '>AUTO<' in _VIEW
|
||||
assert '⚡' not in _VIEW and '✦' not in _VIEW # lightning + sparkle redesigned out
|
||||
assert 'vdl-btn-ic' not in _VIEW # icon spans are gone in the brutalist buttons
|
||||
|
||||
|
||||
def test_header_has_single_auto_best_button():
|
||||
|
|
@ -78,3 +77,30 @@ def test_auto_button_is_styled():
|
|||
assert '.vdl-src-auto' in _CSS
|
||||
assert '.vdl-res--auto' in _CSS # the chosen card gets a ring
|
||||
assert '.vdl-src-actions' in _CSS
|
||||
|
||||
|
||||
# --- Grab whole season (episode-level batch) ------------------------------
|
||||
|
||||
def test_episode_per_source_auto_is_wired():
|
||||
# the show modal now routes the per-episode Auto button (was previously dead),
|
||||
# reusing searchInto + _autoPick at episode scope
|
||||
assert "closest('[data-vdl-auto]')" in _VIEW
|
||||
assert "scope: 'episode'" in _VIEW
|
||||
assert "_autoPick(resA, rowA)" in _VIEW
|
||||
|
||||
|
||||
def test_grab_whole_season_button_and_batch():
|
||||
assert 'data-vdl-season-grab="' in _VIEW # per-season button
|
||||
assert '>Grab season<' in _VIEW
|
||||
assert 'function grabSeason(' in _VIEW
|
||||
assert 'function autoGrabEpisode(' in _VIEW
|
||||
# episode-LEVEL: it loops missing episodes and auto-grabs each (no pack grab)
|
||||
assert "st.epMeta[k].state === 'missing'" in _VIEW
|
||||
assert ".vdl-season-grab" in _CSS
|
||||
|
||||
|
||||
def test_season_grab_reuses_the_single_grab_path():
|
||||
# each episode goes through the same searchInto + _autoPick as a manual Auto,
|
||||
# so the batch can't diverge from the single path
|
||||
assert 'autoGrabEpisode(container, st, sn, eps[idx++], src)' in _VIEW
|
||||
assert 'searchInto(container, panel,' in _VIEW
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ _CSS = (_ROOT / "webui" / "static" / "video" / "video-side.css").read_text(encod
|
|||
def test_result_card_redesigned_as_column_with_get_button():
|
||||
assert 'vdl-res-main' in _VIEW # row wrapper so a tracker can dock below
|
||||
assert 'data-vdl-card="' in _VIEW # addressable card (auto-pick targets it)
|
||||
assert 'vdl-res-grab-ic' in _VIEW # the grab button is now a labelled "Get" pill
|
||||
assert '[ GET ]' in _VIEW # brutalist grab button is a bracketed label
|
||||
assert '.vdl-res-main' in _CSS
|
||||
|
||||
|
||||
|
|
@ -66,16 +66,18 @@ def test_detail_watch_started_for_library_movies():
|
|||
assert 'stopMovieDownloadWatch()' in _DETAIL # cleared on (re)load / navigate away
|
||||
|
||||
|
||||
# --- result cards: cinematic card redesign --------------------------------
|
||||
# --- result cards: flat / brutalist redesign ------------------------------
|
||||
|
||||
def test_result_cards_are_cinematic_cards_with_meta_pills():
|
||||
# quality badge + release-name hero + a row of meta PILLS + size/verdict/Get group.
|
||||
assert 'vdl-info-title' in _VIEW and 'vdl-q-res' in _VIEW and 'vdl-flag' in _VIEW
|
||||
assert 'vdl-info-tags' in _VIEW and 'vdl-tag' in _VIEW and 'vdl-res-right' in _VIEW
|
||||
assert '.vdl-tag' in _CSS and '.vdl-info-tags' in _CSS and '.vdl-res-right' in _CSS
|
||||
# the old "·"-joined sub-line is gone (meta is pills now)
|
||||
assert 'vdl-info-sub' not in _VIEW
|
||||
assert 'vdl-res-summary' not in _VIEW
|
||||
def test_result_cards_are_flat_brutalist_three_line():
|
||||
# 3 hard lines: [QUALITY] + title, an UPPERCASE spec line, then verdict + GET.
|
||||
assert 'vdl-r-l1' in _VIEW and 'vdl-r-q' in _VIEW and 'vdl-r-title' in _VIEW
|
||||
assert 'vdl-r-l2' in _VIEW and 'vdl-r-l3' in _VIEW and 'vdl-r-verdict' in _VIEW
|
||||
assert '.vdl-r-q' in _CSS and '.vdl-r-l2' in _CSS and '.vdl-r-verdict' in _CSS
|
||||
# the cinematic pill/badge language was redesigned out
|
||||
assert 'vdl-info-tags' not in _VIEW and 'vdl-tag' not in _VIEW
|
||||
assert 'vdl-q-res' not in _VIEW and 'vdl-flag' not in _VIEW
|
||||
# sharp corners + monospace are the brutalist signature
|
||||
assert 'border-radius: 0' in _CSS and 'monospace' in _CSS
|
||||
|
||||
|
||||
# --- modal resumes an in-flight download on reopen ------------------------
|
||||
|
|
|
|||
61
tests/test_video_import_page.py
Normal file
61
tests/test_video_import_page.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Video Import page — frontend wiring (string-contract, like the other video page
|
||||
tests). Pins the page module, its container, nav registration, and the endpoints it
|
||||
calls so a refactor can't quietly unhook the manual-import flow. The placement LOGIC
|
||||
itself is covered by tests/test_video_importer.py + tests/test_video_manual_import.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_JS = (_ROOT / "webui" / "static" / "video" / "video-import.js").read_text(encoding="utf-8")
|
||||
_SIDE = (_ROOT / "webui" / "static" / "video" / "video-side.js").read_text(encoding="utf-8")
|
||||
_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||
_CSS = (_ROOT / "webui" / "static" / "video" / "video-side.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_module_is_an_isolated_iife():
|
||||
s = _JS.strip()
|
||||
assert s.startswith("/*") or s.startswith("(function")
|
||||
assert "(function" in _JS and "})();" in _JS
|
||||
# isolated: wrapped in an IIFE and never ASSIGNS a global (reads like
|
||||
# window.confirm are fine). No `window.<name> =` and no `var X` at top level.
|
||||
import re
|
||||
assert not re.search(r"window\.\w+\s*=", _JS)
|
||||
assert "PAGE_ID = 'video-import'" in _JS
|
||||
|
||||
|
||||
def test_page_is_a_real_video_page_not_shared():
|
||||
# the nav entry exists and is NO LONGER flagged shared (it's a true video page now)
|
||||
assert 'data-video-page="video-import"' in _INDEX
|
||||
assert "{ id: 'video-import', label: 'Import' }" in _SIDE
|
||||
assert "'video-import', label: 'Import', shared" not in _SIDE
|
||||
|
||||
|
||||
def test_subpage_container_and_script_present():
|
||||
assert 'data-video-subpage="video-import"' in _INDEX
|
||||
assert "data-vimp-grid" in _INDEX and "data-vimp-empty" in _INDEX
|
||||
assert "video/video-import.js" in _INDEX # script include
|
||||
assert ".vimp-card" in _CSS and ".vimp-modal" in _CSS
|
||||
|
||||
|
||||
def test_loads_and_polls_the_failed_queue():
|
||||
assert "/api/video/import/failed" in _JS
|
||||
assert "soulsync:video-page-shown" in _JS
|
||||
assert "setInterval(" in _JS # 5s poll while shown
|
||||
|
||||
|
||||
def test_resolve_flow_wired_to_place_and_search():
|
||||
# the picker reuses the existing TMDB search, library-owned floated to the top
|
||||
assert "/api/video/search?q=" in _JS
|
||||
assert "library first" in _JS or "owned" in _JS
|
||||
# movie vs episode placement + the place/dismiss endpoints
|
||||
assert "scope: r.kind" in _JS
|
||||
assert "/api/video/import/' + r.item.id + '/place'" in _JS
|
||||
assert "/dismiss'" in _JS
|
||||
|
||||
|
||||
def test_endpoints_registered_on_the_blueprint():
|
||||
init = (_ROOT / "api" / "video" / "__init__.py").read_text(encoding="utf-8")
|
||||
assert "reg_manual_import(bp)" in init
|
||||
272
tests/test_video_importer.py
Normal file
272
tests/test_video_importer.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
"""Video post-process / import — the Radarr/Sonarr step that organises a finished
|
||||
download into the library.
|
||||
|
||||
Covers the decision matrix (import / upgrade-replace / reject) and the orchestration
|
||||
(copy in, carry subtitles, delete a worse existing file, reclaim the source unless
|
||||
it's a torrent). The filesystem is injected so it's all unit-tested without disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from core.video import importer
|
||||
|
||||
|
||||
# ── a recording fake filesystem ──────────────────────────────────────────────
|
||||
class FakeFS:
|
||||
def __init__(self, dirs=None):
|
||||
# dirs: {dirpath: [basename, ...]} — what list_dir returns
|
||||
self.dirs = {k: list(v) for k, v in (dirs or {}).items()}
|
||||
self.made = []
|
||||
self.copied = [] # (src, dst)
|
||||
self.moved = [] # (src, dst)
|
||||
self.saved = [] # (url, dst)
|
||||
self.removed = []
|
||||
|
||||
def list_dir(self, path):
|
||||
return self.dirs.get(str(path), [])
|
||||
|
||||
def makedirs(self, path):
|
||||
self.made.append(str(path))
|
||||
|
||||
def copy(self, src, dst):
|
||||
self.copied.append((src, dst))
|
||||
|
||||
def move(self, src, dst):
|
||||
self.moved.append((src, dst))
|
||||
|
||||
def save_url(self, url, dst):
|
||||
self.saved.append((url, dst))
|
||||
|
||||
def remove(self, path):
|
||||
self.removed.append(path)
|
||||
|
||||
|
||||
def _movie_dl(release, source="soulseek", size=2_000_000_000, root="/lib/movies"):
|
||||
return {
|
||||
"kind": "movie", "title": "The Matrix", "year": 1999, "source": source,
|
||||
"release_title": release, "size_bytes": size, "target_dir": root,
|
||||
"search_ctx": json.dumps({"scope": "movie", "title": "The Matrix", "year": 1999}),
|
||||
}
|
||||
|
||||
|
||||
def _episode_dl(release, root="/lib/tv", season=1, episode=1):
|
||||
return {
|
||||
"kind": "show", "title": "Breaking Bad", "source": "soulseek",
|
||||
"release_title": release, "size_bytes": 1_000_000_000, "target_dir": root,
|
||||
"search_ctx": json.dumps({"scope": "episode", "title": "Breaking Bad",
|
||||
"season": season, "episode": episode, "episode_title": "Pilot"}),
|
||||
}
|
||||
|
||||
|
||||
# ── sanity gate ───────────────────────────────────────────────────────────────
|
||||
def test_rejects_non_video_and_samples():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
assert importer.plan_import(dl, "/dl/x/matrix.nfo", list_dir=lambda d: [])["action"] == "reject"
|
||||
small = _movie_dl("The Matrix 1999 1080p BluRay", size=20_000_000)
|
||||
p = importer.plan_import(small, "/dl/x/sample-matrix.mkv", list_dir=lambda d: [])
|
||||
assert p["action"] == "reject" and "sample" in p["reason"].lower()
|
||||
|
||||
|
||||
def test_rejects_wrong_episode():
|
||||
dl = _episode_dl("Breaking Bad S01E02 1080p WEB-DL", season=1, episode=1)
|
||||
p = importer.plan_import(dl, "/dl/x/bb.s01e02.mkv", list_dir=lambda d: [])
|
||||
assert p["action"] == "reject" and "S01E02" in p["reason"]
|
||||
|
||||
|
||||
def test_rejects_season_pack_for_manual():
|
||||
dl = _episode_dl("Breaking Bad S01 1080p WEB-DL", season=1, episode=1)
|
||||
dl["search_ctx"] = json.dumps({"scope": "season", "title": "Breaking Bad", "season": 1})
|
||||
p = importer.plan_import(dl, "/dl/x/bb.s01.mkv", list_dir=lambda d: [])
|
||||
assert p["action"] == "reject" and "manual" in p["reason"].lower()
|
||||
|
||||
|
||||
# ── import / upgrade / not-an-upgrade ─────────────────────────────────────────
|
||||
def test_fresh_movie_import_path():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay x265")
|
||||
p = importer.plan_import(dl, "/dl/x/the.matrix.1999.1080p.bluray.x265.mkv", list_dir=lambda d: [])
|
||||
assert p["action"] == "import"
|
||||
assert p["dest"]["path"] == os.path.join("/lib/movies", "The Matrix (1999)",
|
||||
"The Matrix (1999) Bluray-1080p.mkv")
|
||||
|
||||
|
||||
def test_upgrade_replaces_worse_existing():
|
||||
dl = _movie_dl("The Matrix 1999 2160p BluRay") # 4K incoming
|
||||
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
||||
fs_dirs = {folder: ["The Matrix (1999) Bluray-720p.mkv"]} # owns 720p
|
||||
p = importer.plan_import(dl, "/dl/x/matrix.2160p.mkv", list_dir=lambda d: fs_dirs.get(d, []))
|
||||
assert p["action"] == "upgrade"
|
||||
assert p["replace_path"] == os.path.join(folder, "The Matrix (1999) Bluray-720p.mkv")
|
||||
|
||||
|
||||
def test_not_an_upgrade_is_rejected():
|
||||
dl = _movie_dl("The Matrix 1999 720p HDTV") # worse than owned
|
||||
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
||||
fs_dirs = {folder: ["The Matrix (1999) Bluray-1080p.mkv"]}
|
||||
p = importer.plan_import(dl, "/dl/x/matrix.720p.mkv", list_dir=lambda d: fs_dirs.get(d, []))
|
||||
assert p["action"] == "reject" and "upgrade" in p["reason"].lower()
|
||||
|
||||
|
||||
# ── ffprobe verification ──────────────────────────────────────────────────────
|
||||
def test_probe_true_resolution_overrides_lying_name():
|
||||
# name claims 1080p; the file is really 720p → tag + folder use the truth
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
probe = {"ok": True, "resolution": "720p", "duration_sec": 8000, "video_codec": "x264"}
|
||||
p = importer.plan_import(dl, "/dl/x/matrix.mkv", list_dir=lambda d: [], probe=probe)
|
||||
assert p["action"] == "import"
|
||||
assert p["dest"]["path"].endswith("The Matrix (1999) Bluray-720p.mkv")
|
||||
|
||||
|
||||
def test_probe_rejects_corrupt_file():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
probe = {"ok": False}
|
||||
p = importer.plan_import(dl, "/dl/x/matrix.mkv", list_dir=lambda d: [], probe=probe)
|
||||
assert p["action"] == "reject" and "corrupt" in p["reason"].lower()
|
||||
|
||||
|
||||
def test_probe_rejects_short_runtime_movie_as_sample():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
probe = {"ok": True, "resolution": "1080p", "duration_sec": 120} # 2 minutes
|
||||
p = importer.plan_import(dl, "/dl/x/matrix.mkv", list_dir=lambda d: [], probe=probe)
|
||||
assert p["action"] == "reject" and "sample" in p["reason"].lower()
|
||||
|
||||
|
||||
def test_probe_resolution_drives_upgrade_decision():
|
||||
# name says 1080p but the file is truly 2160p → beats an owned 1080p copy
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
||||
fs_dirs = {folder: ["The Matrix (1999) Bluray-1080p.mkv"]}
|
||||
probe = {"ok": True, "resolution": "2160p", "duration_sec": 8000}
|
||||
p = importer.plan_import(dl, "/dl/x/matrix.mkv", list_dir=lambda d: fs_dirs.get(d, []), probe=probe)
|
||||
assert p["action"] == "upgrade"
|
||||
|
||||
|
||||
def test_run_import_uses_prober_and_can_reject():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
fs = FakeFS()
|
||||
patch = importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs,
|
||||
prober=lambda p: {"ok": False})
|
||||
assert patch["status"] == "import_failed" and not fs.copied
|
||||
|
||||
|
||||
# ── orchestration via the fake fs ─────────────────────────────────────────────
|
||||
def test_run_import_copies_and_reclaims_source():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
fs = FakeFS()
|
||||
patch = importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs)
|
||||
assert patch["status"] == "completed"
|
||||
assert patch["dest_path"].endswith(os.path.join("The Matrix (1999)", "The Matrix (1999) Bluray-1080p.mkv"))
|
||||
assert fs.copied and fs.copied[0][0] == "/dl/x/matrix.mkv"
|
||||
assert "/dl/x/matrix.mkv" in fs.removed # soulseek source reclaimed
|
||||
|
||||
|
||||
def test_run_import_keeps_torrent_source():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay", source="torrent")
|
||||
fs = FakeFS()
|
||||
importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs)
|
||||
assert "/dl/x/matrix.mkv" not in fs.removed # torrent left seeding
|
||||
|
||||
|
||||
def test_run_import_carries_subtitles_and_deletes_old_on_upgrade():
|
||||
dl = _movie_dl("The Matrix 1999 2160p BluRay")
|
||||
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
||||
fs = FakeFS(dirs={
|
||||
"/dl/x": ["matrix.mkv", "matrix.en.srt", "unrelated.srt"],
|
||||
folder: ["The Matrix (1999) Bluray-720p.mkv"],
|
||||
})
|
||||
patch = importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs)
|
||||
assert patch["status"] == "completed"
|
||||
# the matching sibling sub is carried + renamed to the dest stem, .en preserved
|
||||
subs = [d for _s, d in fs.copied if d.endswith(".en.srt")]
|
||||
assert subs and subs[0].endswith("The Matrix (1999) Bluray-2160p.en.srt")
|
||||
# the worse 720p copy is deleted
|
||||
assert os.path.join(folder, "The Matrix (1999) Bluray-720p.mkv") in fs.removed
|
||||
|
||||
|
||||
# ── manual placement (force + override) ───────────────────────────────────────
|
||||
def test_force_bypasses_sample_and_files_to_override_identity():
|
||||
# a small "sample" file the auto path would reject — forced to a chosen movie
|
||||
dl = _movie_dl("sample.mkv", size=20_000_000)
|
||||
override = {"scope": "movie", "title": "Blade Runner", "year": 1982, "target_dir": "/lib/movies"}
|
||||
p = importer.plan_import(dl, "/dl/x/sample.mkv", list_dir=lambda d: [],
|
||||
force=True, override=override)
|
||||
assert p["action"] == "import"
|
||||
assert p["dest"]["path"] == os.path.join("/lib/movies", "Blade Runner (1982)",
|
||||
"Blade Runner (1982).mkv")
|
||||
|
||||
|
||||
def test_force_reidentifies_wrong_episode_to_the_right_one():
|
||||
dl = _episode_dl("Show S01E02 1080p WEB-DL", season=1, episode=2)
|
||||
override = {"scope": "episode", "title": "Show", "season": 3, "episode": 7,
|
||||
"episode_title": "Reckoning", "target_dir": "/lib/tv"}
|
||||
p = importer.plan_import(dl, "/dl/x/show.mkv", list_dir=lambda d: [],
|
||||
force=True, override=override)
|
||||
assert p["action"] == "import"
|
||||
# re-identified to S03E07, but the file keeps its real quality tag from the release
|
||||
assert p["dest"]["path"] == os.path.join("/lib/tv", "Show", "Season 03",
|
||||
"Show - S03E07 - Reckoning WEBDL-1080p.mkv")
|
||||
|
||||
|
||||
def test_force_replaces_existing_regardless_of_quality():
|
||||
# forcing a 720p over an owned 1080p still places it (the user decided)
|
||||
dl = _movie_dl("The Matrix 1999 720p HDTV")
|
||||
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
||||
fs_dirs = {folder: ["The Matrix (1999) Bluray-1080p.mkv"]}
|
||||
override = {"scope": "movie", "title": "The Matrix", "year": 1999, "target_dir": "/lib/movies"}
|
||||
p = importer.plan_import(dl, "/dl/x/m.mkv", list_dir=lambda d: fs_dirs.get(d, []),
|
||||
force=True, override=override)
|
||||
assert p["action"] == "upgrade"
|
||||
|
||||
|
||||
def test_run_import_reject_remembers_the_file_location():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
fs = FakeFS()
|
||||
patch = importer.run_import(dl, "/dl/x/readme.nfo", fs=fs) # non-video → reject
|
||||
assert patch["status"] == "import_failed"
|
||||
assert patch["dest_path"] == "/dl/x/readme.nfo" # so manual import can find it
|
||||
|
||||
|
||||
# ── organisation settings drive behaviour ─────────────────────────────────────
|
||||
def test_custom_template_changes_the_path():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
settings = {"movie_template": "$year/$title/$title $resolution"}
|
||||
p = importer.plan_import(dl, "/dl/x/matrix.mkv", list_dir=lambda d: [], settings=settings)
|
||||
assert p["dest"]["path"] == os.path.join("/lib/movies", "1999", "The Matrix", "The Matrix 1080p.mkv")
|
||||
|
||||
|
||||
def test_replace_disabled_rejects_instead_of_upgrading():
|
||||
dl = _movie_dl("The Matrix 1999 2160p BluRay")
|
||||
folder = os.path.join("/lib/movies", "The Matrix (1999)")
|
||||
fs_dirs = {folder: ["The Matrix (1999) Bluray-720p.mkv"]}
|
||||
p = importer.plan_import(dl, "/dl/x/m.mkv", list_dir=lambda d: fs_dirs.get(d, []),
|
||||
settings={"replace_existing": False})
|
||||
assert p["action"] == "reject" and "replace is turned off" in p["reason"].lower()
|
||||
|
||||
|
||||
def test_move_mode_moves_and_does_not_reclaim():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
fs = FakeFS()
|
||||
importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs, settings={"transfer_mode": "move"})
|
||||
assert fs.moved and fs.moved[0][0] == "/dl/x/matrix.mkv"
|
||||
assert not fs.copied and not fs.removed # moved, so no copy + no source reclaim
|
||||
|
||||
|
||||
def test_carry_subtitles_toggle_off():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
fs = FakeFS(dirs={"/dl/x": ["matrix.mkv", "matrix.en.srt"]})
|
||||
importer.run_import(dl, "/dl/x/matrix.mkv", fs=fs, settings={"carry_subtitles": False})
|
||||
assert not any(d.endswith(".srt") for _s, d in fs.copied)
|
||||
|
||||
|
||||
# (artwork/NFO sidecars moved out of run_import into core/video/sidecars.py — see
|
||||
# tests/test_video_sidecars.py. run_import is now purely the file mover.)
|
||||
|
||||
|
||||
def test_run_import_reject_leaves_file_and_flags_manual():
|
||||
dl = _movie_dl("The Matrix 1999 1080p BluRay")
|
||||
fs = FakeFS()
|
||||
patch = importer.run_import(dl, "/dl/x/readme.nfo", fs=fs) # not a video file
|
||||
assert patch["status"] == "import_failed" and patch["error"]
|
||||
assert not fs.copied and not fs.removed # nothing touched on disk
|
||||
69
tests/test_video_library_paths.py
Normal file
69
tests/test_video_library_paths.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Video library organisation — the Radarr/Sonarr-standard naming seam.
|
||||
|
||||
A finished download must land at a canonical, server-identifiable path:
|
||||
<root>/The Matrix (1999)/The Matrix (1999) Bluray-1080p.mkv
|
||||
<root>/Breaking Bad/Season 01/Breaking Bad - S01E01 - Pilot WEBDL-1080p.mkv
|
||||
Pure string logic — pinned here so a refactor can't quietly change the layout.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from core.video.library_paths import (
|
||||
episode_filename,
|
||||
movie_filename,
|
||||
movie_folder,
|
||||
plan_path,
|
||||
quality_full,
|
||||
sanitize,
|
||||
season_folder,
|
||||
show_folder,
|
||||
)
|
||||
|
||||
|
||||
def test_sanitize_strips_illegal_and_trailing():
|
||||
assert sanitize('A: Movie / "Title"?') == "A Movie Title"
|
||||
assert sanitize("name with trailing dots... ") == "name with trailing dots"
|
||||
assert sanitize(" spaced out ") == "spaced out"
|
||||
assert sanitize(None) == ""
|
||||
|
||||
|
||||
def test_quality_full_tag():
|
||||
assert quality_full({"source": "bluray", "resolution": "1080p"}) == "Bluray-1080p"
|
||||
assert quality_full({"source": "web-dl", "resolution": "1080p"}) == "WEBDL-1080p"
|
||||
assert quality_full({"source": "remux", "resolution": "2160p"}) == "Remux-2160p"
|
||||
# proper/repack is surfaced in the tag
|
||||
assert quality_full({"source": "bluray", "resolution": "720p", "proper": True}) == "Bluray-720p Proper"
|
||||
# partial / unknown
|
||||
assert quality_full({"resolution": "1080p"}) == "1080p"
|
||||
assert quality_full({}) == ""
|
||||
|
||||
|
||||
def test_movie_naming():
|
||||
assert movie_folder("The Matrix", 1999) == "The Matrix (1999)"
|
||||
assert movie_folder("The Matrix", None) == "The Matrix" # no year → no suffix
|
||||
assert movie_filename("The Matrix", 1999, "Bluray-1080p", ".mkv") == "The Matrix (1999) Bluray-1080p.mkv"
|
||||
assert movie_filename("The Matrix", 1999, "", ".mkv") == "The Matrix (1999).mkv" # unknown quality omitted
|
||||
|
||||
|
||||
def test_episode_naming():
|
||||
assert show_folder("Breaking Bad") == "Breaking Bad"
|
||||
assert season_folder(1) == "Season 01"
|
||||
assert season_folder(0) == "Specials"
|
||||
assert episode_filename("Breaking Bad", 1, 1, "Pilot", "WEBDL-1080p", ".mkv") == \
|
||||
"Breaking Bad - S01E01 - Pilot WEBDL-1080p.mkv"
|
||||
# missing episode title + missing quality both degrade gracefully
|
||||
assert episode_filename("Breaking Bad", 2, 13, "", "", ".mkv") == "Breaking Bad - S02E13.mkv"
|
||||
|
||||
|
||||
def test_plan_path_movie_and_episode():
|
||||
movie = plan_path("movie", "/lib/movies", {"title": "The Matrix", "year": 1999}, "Bluray-1080p", ".mkv")
|
||||
assert movie["dir"] == os.path.join("/lib/movies", "The Matrix (1999)")
|
||||
assert movie["path"] == os.path.join("/lib/movies", "The Matrix (1999)", "The Matrix (1999) Bluray-1080p.mkv")
|
||||
|
||||
ep = plan_path("episode", "/lib/tv",
|
||||
{"title": "Breaking Bad", "season": 1, "episode": 1, "episode_title": "Pilot"},
|
||||
"WEBDL-1080p", ".mkv")
|
||||
assert ep["dir"] == os.path.join("/lib/tv", "Breaking Bad", "Season 01")
|
||||
assert ep["filename"] == "Breaking Bad - S01E01 - Pilot WEBDL-1080p.mkv"
|
||||
120
tests/test_video_manual_import.py
Normal file
120
tests/test_video_manual_import.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Manual / failed-import resolution endpoints — list the unplaced queue, force-place
|
||||
a file to a chosen identity, and dismiss. Uses real temp files (the place flow runs
|
||||
the real importer against disk)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
import api.video as videoapi
|
||||
from core.video import organization
|
||||
from database.video_database import VideoDatabase
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(tmp_path):
|
||||
db = VideoDatabase(database_path=str(tmp_path / "video_library.db"))
|
||||
movies = tmp_path / "Movies"
|
||||
movies.mkdir()
|
||||
db.set_setting("movies_path", str(movies))
|
||||
db.set_setting("tv_path", str(tmp_path / "TV"))
|
||||
organization.save(db, {"verify_with_ffprobe": False}) # don't depend on ffprobe in CI
|
||||
|
||||
dl_dir = tmp_path / "dl"
|
||||
dl_dir.mkdir()
|
||||
src = dl_dir / "the.matrix.1999.1080p.bluray.x265.mkv"
|
||||
src.write_bytes(b"x" * 4096)
|
||||
|
||||
dl_id = db.add_video_download({
|
||||
"kind": "movie", "title": "the matrix", "release_title": src.name,
|
||||
"source": "soulseek", "username": "neo", "filename": src.name,
|
||||
"size_bytes": 4096, "target_dir": str(movies), "status": "import_failed",
|
||||
"search_ctx": "{}",
|
||||
})
|
||||
db.update_video_download(dl_id, dest_path=str(src), error="Looks like a sample, not the feature")
|
||||
|
||||
videoapi._video_db = db
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(videoapi.create_video_blueprint(), url_prefix="/api/video")
|
||||
client = app.test_client()
|
||||
try:
|
||||
yield {"client": client, "db": db, "dl_id": dl_id, "src": src, "movies": movies}
|
||||
finally:
|
||||
videoapi._video_db = None
|
||||
|
||||
|
||||
def test_failed_list_surfaces_unplaced_downloads(env):
|
||||
items = env["client"].get("/api/video/import/failed").get_json()["items"]
|
||||
assert len(items) == 1
|
||||
it = items[0]
|
||||
assert it["id"] == env["dl_id"]
|
||||
assert it["file"] == str(env["src"]) # points at the unplaced file
|
||||
assert "sample" in it["reason"].lower()
|
||||
|
||||
|
||||
def test_place_force_imports_to_chosen_identity(env):
|
||||
r = env["client"].post("/api/video/import/%d/place" % env["dl_id"],
|
||||
json={"scope": "movie", "title": "The Matrix", "year": 1999}).get_json()
|
||||
assert r["success"] and r["status"] == "completed"
|
||||
final = env["movies"] / "The Matrix (1999)" / "The Matrix (1999) Bluray-1080p.mkv"
|
||||
assert final.exists() # filed under the standard layout
|
||||
assert not env["src"].exists() # source reclaimed (copy mode, non-torrent)
|
||||
# the row is no longer in the failed queue
|
||||
assert env["client"].get("/api/video/import/failed").get_json()["items"] == []
|
||||
|
||||
|
||||
def test_place_triggers_a_library_refresh(env):
|
||||
# a successful manual place fires the same batch-complete refresh the auto path
|
||||
# uses, so the title shows up without waiting for a scheduled scan.
|
||||
from core.video import download_events
|
||||
fired = []
|
||||
download_events.register_batch_complete_callback(lambda d: fired.append(d))
|
||||
try:
|
||||
env["client"].post("/api/video/import/%d/place" % env["dl_id"],
|
||||
json={"scope": "movie", "title": "The Matrix", "year": 1999})
|
||||
assert fired and fired[-1].get("manual") is True
|
||||
finally:
|
||||
download_events._reset_for_tests()
|
||||
|
||||
|
||||
def test_media_ids_resolves_tmdb_and_library_regrabs():
|
||||
from core.video.download_monitor import _media_ids
|
||||
# grabbed straight from TMDB → media_id is the tmdb id
|
||||
assert _media_ids(None, {"media_source": "tmdb", "media_id": "603"}) == (603, None)
|
||||
|
||||
# owned re-grab → media_id is the LIBRARY id; resolve via the library row
|
||||
class _DB:
|
||||
def media_tmdb_id(self, kind, mid):
|
||||
assert kind == "movie" and mid == "5107"
|
||||
return (936075, "tt11378946")
|
||||
assert _media_ids(_DB(), {"media_source": "library", "media_id": "5107", "kind": "movie"}) \
|
||||
== (936075, "tt11378946")
|
||||
|
||||
assert _media_ids(None, {}) == (None, None) # unresolvable → no sidecars
|
||||
|
||||
|
||||
def test_dismiss_does_not_trigger_a_refresh(env):
|
||||
from core.video import download_events
|
||||
fired = []
|
||||
download_events.register_batch_complete_callback(lambda d: fired.append(d))
|
||||
try:
|
||||
env["client"].post("/api/video/import/%d/dismiss" % env["dl_id"], json={})
|
||||
assert fired == [] # nothing landed → no scan
|
||||
finally:
|
||||
download_events._reset_for_tests()
|
||||
|
||||
|
||||
def test_place_rejects_bad_scope(env):
|
||||
r = env["client"].post("/api/video/import/%d/place" % env["dl_id"], json={"scope": "season"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_dismiss_drops_row_and_can_delete_file(env):
|
||||
r = env["client"].post("/api/video/import/%d/dismiss" % env["dl_id"],
|
||||
json={"delete_file": True}).get_json()
|
||||
assert r["success"]
|
||||
assert not env["src"].exists() # file removed
|
||||
assert env["client"].get("/api/video/import/failed").get_json()["items"] == []
|
||||
60
tests/test_video_mediainfo.py
Normal file
60
tests/test_video_mediainfo.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""ffprobe-backed media verification — the pure parsing seam.
|
||||
|
||||
We trust the FILE over the scene name: real dimensions → resolution, real duration to
|
||||
catch samples, real codec. ffprobe (the subprocess) is injected, so these run without
|
||||
ffmpeg installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from core.video.mediainfo import parse_ffprobe, probe, resolution_from_dimensions
|
||||
|
||||
|
||||
def test_resolution_buckets_use_the_long_axis():
|
||||
assert resolution_from_dimensions(3840, 2160) == "2160p"
|
||||
assert resolution_from_dimensions(1920, 1080) == "1080p"
|
||||
# a letterboxed 1080p movie is 1920x800 — must NOT read as 720p by its short side
|
||||
assert resolution_from_dimensions(1920, 800) == "1080p"
|
||||
assert resolution_from_dimensions(1280, 720) == "720p"
|
||||
assert resolution_from_dimensions(720, 480) == "480p"
|
||||
assert resolution_from_dimensions(0, 0) is None
|
||||
|
||||
|
||||
def _ffprobe_json(width, height, duration, vcodec="hevc", acodec="eac3"):
|
||||
return json.dumps({
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": vcodec, "width": width, "height": height},
|
||||
{"codec_type": "audio", "codec_name": acodec},
|
||||
],
|
||||
"format": {"duration": str(duration)},
|
||||
})
|
||||
|
||||
|
||||
def test_parse_ffprobe_extracts_real_info():
|
||||
info = parse_ffprobe(json.loads(_ffprobe_json(1920, 1080, 7200.0)))
|
||||
assert info["ok"] is True
|
||||
assert info["resolution"] == "1080p"
|
||||
assert info["duration_sec"] == 7200.0
|
||||
assert info["video_codec"] == "hevc"
|
||||
assert info["audio_codec"] == "eac3"
|
||||
|
||||
|
||||
def test_parse_ffprobe_no_video_stream_is_not_ok():
|
||||
data = {"streams": [{"codec_type": "audio", "codec_name": "mp3"}], "format": {"duration": "200"}}
|
||||
assert parse_ffprobe(data)["ok"] is False
|
||||
assert parse_ffprobe({})["ok"] is False
|
||||
|
||||
|
||||
def test_probe_runs_injected_runner():
|
||||
info = probe("/x/movie.mkv", runner=lambda p: _ffprobe_json(3840, 2160, 8000))
|
||||
assert info["ok"] and info["resolution"] == "2160p"
|
||||
|
||||
|
||||
def test_probe_returns_none_when_unverifiable():
|
||||
assert probe("/x/movie.mkv", runner=lambda p: None) is None # ffprobe couldn't run
|
||||
assert probe("/x/movie.mkv", runner=lambda p: "not json") is None # garbage output
|
||||
def boom(_p):
|
||||
raise OSError("ffprobe exploded")
|
||||
assert probe("/x/movie.mkv", runner=boom) is None # crash → unverified
|
||||
85
tests/test_video_organization.py
Normal file
85
tests/test_video_organization.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""Video library-organisation settings — the $token template engine + the settings
|
||||
model. Same standard as the music side's file-organisation templates, for video's
|
||||
movie/episode shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from core.video import organization
|
||||
from core.video.library_paths import plan_path
|
||||
|
||||
|
||||
# ── settings model ────────────────────────────────────────────────────────────
|
||||
def test_normalize_fills_and_validates():
|
||||
d = organization.normalize(None)
|
||||
assert d == organization.default_settings()
|
||||
d = organization.normalize({"transfer_mode": "MOVE", "verify_with_ffprobe": 0,
|
||||
"movie_template": " ", "episode_template": "$series/$episode"})
|
||||
assert d["transfer_mode"] == "move"
|
||||
assert d["verify_with_ffprobe"] is False
|
||||
assert organization.default_settings()["save_artwork"] is False # off by default
|
||||
assert organization.normalize({"save_artwork": 1})["save_artwork"] is True
|
||||
assert organization.default_settings()["download_subtitles"] is False
|
||||
assert organization.default_settings()["subtitle_langs"] == "en"
|
||||
assert organization.normalize({"subtitle_langs": "EN, es"})["subtitle_langs"] == "en,es"
|
||||
assert d["movie_template"] == organization.DEFAULTS["movie_template"] # blank → default
|
||||
assert d["episode_template"] == "$series/$episode"
|
||||
# an invalid transfer mode falls back to the default
|
||||
assert organization.normalize({"transfer_mode": "torrent"})["transfer_mode"] == "copy"
|
||||
|
||||
|
||||
def test_load_save_roundtrip():
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self.store = {}
|
||||
|
||||
def get_setting(self, key, default=None):
|
||||
return self.store.get(key, default)
|
||||
|
||||
def set_setting(self, key, value):
|
||||
self.store[key] = value
|
||||
|
||||
db = FakeDB()
|
||||
assert organization.load(db) == organization.default_settings() # nothing stored yet
|
||||
saved = organization.save(db, {"transfer_mode": "move"})
|
||||
assert saved["transfer_mode"] == "move"
|
||||
assert organization.load(db)["transfer_mode"] == "move" # persisted + reloads
|
||||
|
||||
|
||||
# ── template rendering ────────────────────────────────────────────────────────
|
||||
def test_default_movie_template_matches_the_standard():
|
||||
fields = {"title": "The Matrix", "year": 1999, "quality": "Bluray-1080p"}
|
||||
got = organization.render_path("movie", "/m", fields, organization.default_settings(), ".mkv")
|
||||
std = plan_path("movie", "/m", {"title": "The Matrix", "year": 1999}, "Bluray-1080p", ".mkv")
|
||||
assert got["path"] == std["path"] # default template == the hardcoded Radarr standard
|
||||
|
||||
|
||||
def test_default_episode_template_matches_the_standard():
|
||||
fields = {"title": "Breaking Bad", "season": 1, "episode": 1,
|
||||
"episode_title": "Pilot", "quality": "WEBDL-1080p"}
|
||||
got = organization.render_path("episode", "/t", fields, organization.default_settings(), ".mkv")
|
||||
assert got["path"] == os.path.join("/t", "Breaking Bad", "Season 01",
|
||||
"Breaking Bad - S01E01 - Pilot WEBDL-1080p.mkv")
|
||||
|
||||
|
||||
def test_empty_tokens_dont_leave_dangling_separators():
|
||||
# no episode title, no quality → no stray ' - ' or double spaces
|
||||
fields = {"title": "Show", "season": 2, "episode": 5, "episode_title": "", "quality": ""}
|
||||
got = organization.render_path("episode", "/t", fields, organization.default_settings(), ".mkv")
|
||||
assert got["filename"] == "Show - S02E05.mkv"
|
||||
|
||||
|
||||
def test_missing_year_doesnt_leave_empty_parens():
|
||||
fields = {"title": "Unknownish", "year": None, "quality": "Bluray-1080p"}
|
||||
got = organization.render_path("movie", "/m", fields, organization.default_settings(), ".mkv")
|
||||
assert "()" not in got["path"]
|
||||
assert got["dir"] == os.path.join("/m", "Unknownish")
|
||||
|
||||
|
||||
def test_token_values_cannot_inject_extra_folders():
|
||||
# a slash inside a title is sanitised, not treated as a path separator
|
||||
fields = {"title": "AC/DC Live", "year": 2020, "quality": "Bluray-1080p"}
|
||||
got = organization.render_path("movie", "/m", fields, organization.default_settings(), ".mkv")
|
||||
assert got["dir"] == os.path.join("/m", "ACDC Live (2020)")
|
||||
|
|
@ -25,9 +25,32 @@ _CSS_PATH = _ROOT / "webui" / "static" / "video" / "video-side.css"
|
|||
EXPECTED_VIDEO_PAGES = {
|
||||
"video-dashboard", "video-search", "video-discover", "video-library",
|
||||
"video-watchlist", "video-wishlist", "video-downloads", "video-calendar",
|
||||
"video-tools", "video-import", "video-settings", "video-issues", "video-help",
|
||||
"video-automations", "video-tools", "video-import", "video-settings",
|
||||
"video-issues", "video-help",
|
||||
}
|
||||
|
||||
# Reads of sibling VIDEO-side module handles + standard DOM/browser APIs are fine —
|
||||
# what the isolation guard actually forbids is reaching into a MUSIC global (or
|
||||
# leaking a foreign one). So we check every window.* reference is a known-legit one
|
||||
# rather than banning the substring outright (which wrongly flags window.confirm,
|
||||
# window.VideoGet, window.addEventListener, …).
|
||||
_ALLOWED_WINDOW = {
|
||||
# sibling video-side module namespaces (each published by its own IIFE)
|
||||
"window.VideoGet", "window.VideoWatchlist", "window.VideoYoutube", "window.VideoDownload",
|
||||
"window.videoWorkerOrbs", "window._videoWorkerOrbsEnabled",
|
||||
"window._buildAutomationSection", "window._buildAutomationHub",
|
||||
"window._reloadVideoAutomations", "window._vdpgAnyActive", "window._reduceEffectsActive",
|
||||
# standard DOM / browser APIs
|
||||
"window.location", "window.history", "window.matchMedia", "window.addEventListener",
|
||||
"window.removeEventListener", "window.innerWidth", "window.confirm",
|
||||
}
|
||||
|
||||
|
||||
def _window_isolated(src: str) -> bool:
|
||||
"""True when every ``window.*`` reference is a sibling-video handle or a DOM API
|
||||
(no music global leaked in)."""
|
||||
return set(re.findall(r"window\.\w+", src)) <= _ALLOWED_WINDOW
|
||||
|
||||
|
||||
def _block(html: str, open_tag_re: str, close_tag: str) -> str:
|
||||
"""Return the substring from the first match of open_tag_re to the next close_tag."""
|
||||
|
|
@ -161,7 +184,7 @@ def test_video_library_module_referenced_and_isolated():
|
|||
# Cards/poster URLs use the SINGULAR kind (movie/show); the API uses plural.
|
||||
assert "cardKind" in _LIB_JS and "apiKind" in _LIB_JS
|
||||
assert "addEventListener" in _LIB_JS
|
||||
assert "window." not in _LIB_JS
|
||||
assert _window_isolated(_LIB_JS) # only sibling-video handles + DOM APIs
|
||||
|
||||
|
||||
def test_video_tools_page_has_three_scan_modes():
|
||||
|
|
@ -395,7 +418,7 @@ def test_video_detail_module_referenced_and_isolated():
|
|||
assert "video/video-detail.js" in _INDEX
|
||||
src = (_ROOT / "webui" / "static" / "video" / "video-detail.js").read_text(encoding="utf-8")
|
||||
assert "(function" in src and "})();" in src
|
||||
assert "window." not in src # declares no globals
|
||||
assert _window_isolated(src) # no music globals leaked in
|
||||
assert "/api/video/detail/" in src # video API only
|
||||
assert "/api/enrichment/" not in src and "artist-detail" not in src
|
||||
assert "soulsync:video-open-detail" in src # opened via the shared event
|
||||
|
|
@ -407,7 +430,7 @@ def test_search_subpage_and_module():
|
|||
assert "video/video-search.js" in _INDEX
|
||||
src = (_ROOT / "webui" / "static" / "video" / "video-search.js").read_text(encoding="utf-8")
|
||||
assert "(function" in src and "})();" in src
|
||||
assert "window." not in src # no globals
|
||||
assert _window_isolated(src) # no music globals leaked in
|
||||
assert "/api/video/search" in src
|
||||
assert "/api/video/trending" in src # idle page shows a trending rail
|
||||
assert "soulsync:video-open-detail" in src # results drill in via the shared event
|
||||
|
|
@ -420,7 +443,7 @@ def test_person_subpage_and_module_isolated():
|
|||
assert "video/video-person.js" in _INDEX
|
||||
src = (_ROOT / "webui" / "static" / "video" / "video-person.js").read_text(encoding="utf-8")
|
||||
assert "(function" in src and "})();" in src
|
||||
assert "window." not in src
|
||||
assert _window_isolated(src) # no music globals leaked in
|
||||
assert "/api/video/person/" in src
|
||||
assert "themoviedb.org" not in src and "imdb.com" not in src
|
||||
# The person page is a registered detail route (reload / back / new-tab work).
|
||||
|
|
|
|||
124
tests/test_video_sidecars.py
Normal file
124
tests/test_video_sidecars.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Kodi/Jellyfin sidecar writer — NFO building + the artwork-set write plan. Pure
|
||||
logic (XML + plan) with the filesystem injected, so it runs without disk or network.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from core.video import sidecars
|
||||
|
||||
|
||||
class FakeFS:
|
||||
def __init__(self, dirs=None):
|
||||
self.dirs = {k: list(v) for k, v in (dirs or {}).items()}
|
||||
self.made = []
|
||||
self.saved = [] # (url, dst) — artwork
|
||||
self.texts = [] # (path, content) — nfo
|
||||
|
||||
def list_dir(self, path):
|
||||
return self.dirs.get(str(path), [])
|
||||
|
||||
def makedirs(self, path):
|
||||
self.made.append(str(path))
|
||||
|
||||
def save_url(self, url, dst):
|
||||
self.saved.append((url, dst))
|
||||
|
||||
def write_text(self, path, content):
|
||||
self.texts.append((path, content))
|
||||
|
||||
|
||||
_MOVIE = {
|
||||
"title": "The Matrix", "year": "1999", "overview": "A hacker learns the truth.",
|
||||
"tagline": "Free your mind.", "runtime_minutes": 136, "studio": "Warner Bros.",
|
||||
"release_date": "1999-03-31", "rating": 8.2, "genres": ["Action", "Science Fiction"],
|
||||
"tmdb_id": 603, "imdb_id": "tt0133093",
|
||||
"poster_url": "http://img/p.jpg", "backdrop_url": "http://img/b.jpg", "logo": "http://img/l.png",
|
||||
"cast": [{"name": "Keanu Reeves", "character": "Neo"}, {"name": "Carrie-Anne Moss", "character": "Trinity"}],
|
||||
}
|
||||
_SHOW = {
|
||||
"title": "Breaking Bad", "year": "2008", "overview": "A teacher cooks.",
|
||||
"network": "AMC", "first_air_date": "2008-01-20", "rating": 9.5,
|
||||
"genres": ["Drama"], "tmdb_id": 1396, "tvdb_id": 81189, "imdb_id": "tt0903747",
|
||||
"poster_url": "http://img/bbp.jpg", "backdrop_url": "http://img/bbb.jpg", "logo": "http://img/bbl.png",
|
||||
"_seasons": [{"season_number": 1, "poster_url": "http://img/s1.jpg"},
|
||||
{"season_number": 2, "poster_url": "http://img/s2.jpg"}],
|
||||
}
|
||||
|
||||
|
||||
# ── NFO building ──────────────────────────────────────────────────────────────
|
||||
def test_movie_nfo_has_the_key_fields():
|
||||
xml = sidecars.nfo_movie(_MOVIE)
|
||||
assert xml.startswith("<?xml")
|
||||
assert "<movie>" in xml and "</movie>" in xml
|
||||
assert "<title>The Matrix</title>" in xml
|
||||
assert "<year>1999</year>" in xml
|
||||
assert "<plot>A hacker learns the truth.</plot>" in xml
|
||||
assert "<genre>Action</genre>" in xml and "<genre>Science Fiction</genre>" in xml
|
||||
assert '<uniqueid type="tmdb" default="true">603</uniqueid>' in xml
|
||||
assert '<uniqueid type="imdb">tt0133093</uniqueid>' in xml
|
||||
assert "<name>Keanu Reeves</name>" in xml and "<role>Neo</role>" in xml
|
||||
|
||||
|
||||
def test_tvshow_nfo_uses_show_fields():
|
||||
xml = sidecars.nfo_tvshow(_SHOW)
|
||||
assert "<tvshow>" in xml and "<studio>AMC</studio>" in xml # network → studio
|
||||
assert "<premiered>2008-01-20</premiered>" in xml
|
||||
assert '<uniqueid type="tmdb" default="true">1396</uniqueid>' in xml
|
||||
|
||||
|
||||
def test_nfo_omits_absent_fields_and_escapes():
|
||||
xml = sidecars.nfo_movie({"title": "A & B <x>", "tmdb_id": 1})
|
||||
assert "<title>A & B <x></title>" in xml
|
||||
assert "<plot>" not in xml and "<genre>" not in xml # absent → omitted, not blank
|
||||
|
||||
|
||||
# ── write plan ────────────────────────────────────────────────────────────────
|
||||
def test_plan_gates_on_settings():
|
||||
none = sidecars.plan_sidecars("movie", _MOVIE, {}) # both off
|
||||
assert none["nfo"] is None and none["art"] == []
|
||||
both = sidecars.plan_sidecars("movie", _MOVIE, {"save_artwork": True, "write_nfo": True})
|
||||
assert both["nfo"][0] == "movie.nfo"
|
||||
names = [n for _u, n in both["art"]]
|
||||
assert names == ["poster.jpg", "fanart.jpg", "clearlogo.png"]
|
||||
|
||||
|
||||
def test_show_plan_includes_season_posters_and_tvshow_nfo():
|
||||
plan = sidecars.plan_sidecars("episode", _SHOW, {"save_artwork": True, "write_nfo": True})
|
||||
assert plan["nfo"][0] == "tvshow.nfo"
|
||||
names = [n for _u, n in plan["art"]]
|
||||
assert "season01-poster.jpg" in names and "season02-poster.jpg" in names
|
||||
|
||||
|
||||
# ── write (idempotent, best-effort) ───────────────────────────────────────────
|
||||
def test_write_emits_files_and_skips_existing():
|
||||
fs = FakeFS(dirs={"/lib/movies/The Matrix (1999)": ["poster.jpg"]}) # poster already there
|
||||
sidecars.write("/lib/movies/The Matrix (1999)", "movie", _MOVIE,
|
||||
{"save_artwork": True, "write_nfo": True}, fs)
|
||||
art = [n for _u, n in fs.saved]
|
||||
assert "fanart.jpg" in [os.path.basename(p) for p in art]
|
||||
assert "poster.jpg" not in [os.path.basename(p) for p in art] # skipped (already present)
|
||||
assert fs.texts and fs.texts[0][0].endswith("movie.nfo")
|
||||
|
||||
|
||||
def test_write_for_resolves_movie_folder_and_show_root():
|
||||
fs = FakeFS()
|
||||
sidecars.write_for("/lib/movies/The Matrix (1999)/The Matrix (1999) Bluray-1080p.mkv",
|
||||
"movie", "http://img/p.jpg", _MOVIE, {"save_artwork": True}, fs)
|
||||
assert any(d.startswith("/lib/movies/The Matrix (1999)/") for _u, d in fs.saved)
|
||||
|
||||
fs2 = FakeFS()
|
||||
sidecars.write_for("/lib/tv/Breaking Bad/Season 01/Breaking Bad - S01E01.mkv",
|
||||
"episode", "http://img/bbp.jpg", _SHOW, {"save_artwork": True}, fs2)
|
||||
# show-level art lands in the show ROOT, not the Season folder
|
||||
assert all("/Season 01/" not in d for _u, d in fs2.saved)
|
||||
assert any(d == os.path.join("/lib/tv", "Breaking Bad", "poster.jpg") for _u, d in fs2.saved)
|
||||
|
||||
|
||||
def test_write_for_poster_only_when_no_detail():
|
||||
# detail fetch unavailable → still drops the poster from the download-row url
|
||||
fs = FakeFS()
|
||||
sidecars.write_for("/lib/movies/X (2020)/X (2020).mkv", "movie",
|
||||
"http://img/p.jpg", None, {"save_artwork": True}, fs)
|
||||
assert [os.path.basename(d) for _u, d in fs.saved] == ["poster.jpg"]
|
||||
88
tests/test_video_subtitles.py
Normal file
88
tests/test_video_subtitles.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""External subtitle download (OpenSubtitles) — pure parsing + the write loop. The
|
||||
HTTP fetch and filesystem are injected, so it runs without network or disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from core.video import subtitles
|
||||
|
||||
|
||||
def test_parse_langs():
|
||||
assert subtitles.parse_langs("en, es ; fr") == ["en", "es", "fr"]
|
||||
assert subtitles.parse_langs("EN en") == ["en"] # lower + dedupe
|
||||
assert subtitles.parse_langs("") == ["en"] # default
|
||||
|
||||
|
||||
def test_pick_best_file_takes_most_downloaded_for_the_language():
|
||||
found = {"data": [
|
||||
{"attributes": {"language": "en", "download_count": 10, "files": [{"file_id": 111}]}},
|
||||
{"attributes": {"language": "en", "download_count": 99, "files": [{"file_id": 222}]}},
|
||||
{"attributes": {"language": "es", "download_count": 500, "files": [{"file_id": 333}]}},
|
||||
]}
|
||||
assert subtitles.pick_best_file(found, "en") == 222 # most-downloaded English
|
||||
assert subtitles.pick_best_file(found, "de") is None # none for German
|
||||
|
||||
|
||||
def test_search_params_movie_vs_episode():
|
||||
movie = subtitles.search_params({"tmdb_id": 603, "imdb_id": "tt0133093"}, "en")
|
||||
assert movie["imdb_id"] == "0133093" and movie["languages"] == "en" # imdb wins, tt stripped
|
||||
ep = subtitles.search_params({"tmdb_id": 1396, "season": 1, "episode": 3}, "en")
|
||||
assert ep["parent_tmdb_id"] == 1396 and ep["season_number"] == 1 and ep["episode_number"] == 3
|
||||
assert subtitles.search_params({}, "en") is None # unidentified
|
||||
|
||||
|
||||
def test_srt_name():
|
||||
assert subtitles.srt_name("/lib/M (2020)/M (2020) Bluray-1080p.mkv", "en") == \
|
||||
"M (2020) Bluray-1080p.en.srt"
|
||||
|
||||
|
||||
# ── the write loop ────────────────────────────────────────────────────────────
|
||||
class FakeFS:
|
||||
def __init__(self, dirs=None):
|
||||
self.dirs = {k: list(v) for k, v in (dirs or {}).items()}
|
||||
self.texts = [] # (path, content)
|
||||
|
||||
def list_dir(self, path):
|
||||
return self.dirs.get(str(path), [])
|
||||
|
||||
def write_text(self, path, content):
|
||||
self.texts.append((path, content))
|
||||
|
||||
|
||||
def test_write_subtitles_fetches_each_missing_language():
|
||||
fs = FakeFS()
|
||||
calls = []
|
||||
|
||||
def fetch(identity, lang):
|
||||
calls.append(lang)
|
||||
return "1\n00:00 --> 00:01\n[%s]\n" % lang
|
||||
|
||||
subtitles.write_subtitles("/lib/M (2020)/M (2020).mkv", ["en", "es"], {"tmdb_id": 1}, fetch, fs)
|
||||
assert calls == ["en", "es"]
|
||||
names = [os.path.basename(p) for p, _c in fs.texts]
|
||||
assert names == ["M (2020).en.srt", "M (2020).es.srt"]
|
||||
|
||||
|
||||
def test_write_subtitles_skips_languages_already_present():
|
||||
fs = FakeFS(dirs={"/lib/M (2020)": ["M (2020).en.srt"]}) # already have English
|
||||
pulled = []
|
||||
subtitles.write_subtitles("/lib/M (2020)/M (2020).mkv", ["en", "es"], {"tmdb_id": 1},
|
||||
lambda i, l: pulled.append(l) or "x", fs)
|
||||
assert pulled == ["es"] # only the missing one fetched
|
||||
|
||||
|
||||
def test_write_subtitles_best_effort_on_fetch_failure():
|
||||
fs = FakeFS()
|
||||
|
||||
def boom(identity, lang):
|
||||
raise RuntimeError("quota exceeded")
|
||||
|
||||
subtitles.write_subtitles("/lib/M/M.mkv", ["en"], {"tmdb_id": 1}, boom, fs)
|
||||
assert fs.texts == [] # nothing written, no raise
|
||||
|
||||
|
||||
def test_no_fetcher_without_a_key():
|
||||
assert subtitles.opensubtitles_fetcher("") is None
|
||||
assert subtitles.opensubtitles_fetcher(None) is None
|
||||
116
webui/index.html
116
webui/index.html
|
|
@ -1282,6 +1282,33 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Import page — downloads that finished but couldn't be auto-placed
|
||||
(sample / wrong episode / not-an-upgrade / corrupt). Resolve each by
|
||||
hand: pick the right title (library-first → TMDB) and place, or
|
||||
dismiss. Driven by video/video-import.js via data-vimp-* hooks. -->
|
||||
<section class="video-subpage" data-video-subpage="video-import" hidden>
|
||||
<div class="vimp-page">
|
||||
<div class="vimp-head">
|
||||
<div class="vimp-head-titles">
|
||||
<h1 class="vimp-title">Import <span class="vimp-count" data-vimp-count></span></h1>
|
||||
<p class="vimp-sub">Downloads that need a hand — place them by hand or dismiss</p>
|
||||
</div>
|
||||
<button class="vimp-refresh" type="button" data-vimp-refresh>Refresh</button>
|
||||
</div>
|
||||
<div class="vimp-body">
|
||||
<div class="vimp-loading hidden" data-vimp-loading>
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">Loading…</div>
|
||||
</div>
|
||||
<div class="vimp-grid" data-vimp-grid></div>
|
||||
<div class="vimp-state hidden" data-vimp-empty>
|
||||
<div class="vimp-state-ic">✓</div>
|
||||
<div class="vimp-state-title">Nothing needs attention</div>
|
||||
<div class="vimp-state-sub">Finished downloads that can't be auto-filed show up here for manual placement.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Downloads page — every grab from the video side lives here (live status).
|
||||
Reuses the music downloads page's .adl-* layout/look for parity;
|
||||
driven by video/video-downloads-page.js via data-vdpg-* hooks. -->
|
||||
|
|
@ -5874,6 +5901,94 @@
|
|||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Video Library Organization — uses the SAME settings vocabulary as the
|
||||
music "File Organization" section (.settings-group / .form-group /
|
||||
.checkbox-label / .form-select / .settings-hint / .test-button). -->
|
||||
<div class="settings-group" data-stg="library" data-video-only>
|
||||
<h3>📁 Library Organization</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Movie Path Template:</label>
|
||||
<input type="text" id="vo-movie-template" placeholder="$title ($year)/$title ($year) $quality">
|
||||
<small class="settings-hint">Variables: $title, $titlefirst, $year, $quality (e.g. Bluray-1080p), $resolution, $source, $codec, $edition, $tmdbid, $imdbid. Folders split on "/", the last segment is the filename.</small>
|
||||
<small class="settings-hint">Example: <code id="vo-movie-preview">…</code></small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Episode Path Template:</label>
|
||||
<input type="text" id="vo-episode-template" placeholder="$series/Season $season/$series - S$seasonE$episode - $episodetitle $quality">
|
||||
<small class="settings-hint">Variables: $series, $season (01), $seasonraw (1), $episode (01), $episodetitle, $year, $quality, $resolution, $source, $codec, $tvdbid.</small>
|
||||
<small class="settings-hint">Example: <code id="vo-episode-preview">…</code></small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>When a file finishes:</label>
|
||||
<select id="vo-transfer-mode" class="form-select">
|
||||
<option value="copy">Copy in, then remove the source (kept for torrents)</option>
|
||||
<option value="move">Move into the library</option>
|
||||
</select>
|
||||
<small class="settings-hint">How a finished download moves from the download folder into your library.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="vo-verify">
|
||||
<span>Verify with ffprobe</span>
|
||||
</label>
|
||||
<small class="settings-hint">Tag by the file's real resolution and reject corrupt / sample files. Needs ffmpeg; falls back to the release name when absent.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="vo-replace">
|
||||
<span>Replace an existing copy when the new one is better</span>
|
||||
</label>
|
||||
<small class="settings-hint">Deletes the old file when an upgrade lands.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="vo-subs">
|
||||
<span>Carry matching subtitles</span>
|
||||
</label>
|
||||
<small class="settings-hint">Bring sibling .srt / .ass files alongside the video, renamed to match.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="vo-artwork">
|
||||
<span>Save artwork into the folder</span>
|
||||
</label>
|
||||
<small class="settings-hint">Download the full artwork set — poster.jpg, fanart.jpg, clearlogo.png (plus season posters for shows) — next to the movie / into the show folder. Off by default; Plex/Jellyfin usually fetch their own once the folder is named cleanly. Turn on to pin specific art or for offline/local-assets setups.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="vo-nfo">
|
||||
<span>Write NFO metadata files</span>
|
||||
</label>
|
||||
<small class="settings-hint">Write Kodi/Jellyfin-style movie.nfo / tvshow.nfo (title, plot, rating, genres, cast, ids) so the library is fully portable. Off by default — Plex ignores these, but Jellyfin/Kodi/Emby use them.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="vo-subs-dl">
|
||||
<span>Download subtitles</span>
|
||||
</label>
|
||||
<small class="settings-hint">Fetch external .srt files from OpenSubtitles next to the video (uses your OpenSubtitles API key on the enrichment settings). Off by default — the free tier is daily-quota-limited, and most releases already carry embedded subs.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Subtitle Languages:</label>
|
||||
<input type="text" id="vo-sub-langs" placeholder="en">
|
||||
<small class="settings-hint">Comma-separated language codes to fetch, in priority order (e.g. <code>en,es</code>). One .srt per language is written as <video>.<lang>.srt.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button class="test-button" id="vo-reset" style="background: #666;">🔄 Reset to Defaults</button>
|
||||
<small class="settings-hint">Restores the Radarr/Sonarr standard layout.</small>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Video Preferences — lives on the Library settings tab -->
|
||||
<div class="settings-group" data-stg="library" data-video-only>
|
||||
<h3>Video Preferences</h3>
|
||||
|
|
@ -10336,6 +10451,7 @@
|
|||
<script src="{{ url_for('static', filename='video/video-watchlist.js', v=static_v) }}"></script>
|
||||
<!-- Video wishlist page (isolated; tabbed movies / TV, grouped show tree) -->
|
||||
<script src="{{ url_for('static', filename='video/video-wishlist.js', v=static_v) }}"></script>
|
||||
<script src="{{ url_for('static', filename='video/video-import.js', v=static_v) }}"></script>
|
||||
<!-- Video worker orbs (isolated copy of the music orbs; idles into the
|
||||
floating-orb animation around the SoulSync logo on the video dashboard) -->
|
||||
<script src="{{ url_for('static', filename='video/video-worker-orbs.js', v=static_v) }}"></script>
|
||||
|
|
|
|||
|
|
@ -57,8 +57,7 @@
|
|||
'<div class="vdl-sec-head">' +
|
||||
'<div class="vdl-sec-label">Sources</div>' +
|
||||
'<span class="vdl-src-actions vdl-src-actions--head">' +
|
||||
'<button class="vdl-search-all vdl-auto-all" type="button" data-vdl-auto-best title="Search every source, then download the single best release for your quality profile">' +
|
||||
'<span class="vdl-btn-ic vdl-btn-ic--auto" aria-hidden="true">✦</span><span>Auto</span></button>' +
|
||||
'<button class="vdl-search-all vdl-auto-all" type="button" data-vdl-auto-best title="Search every source, then download the single best release for your quality profile">AUTO</button>' +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div class="vdl-sources" data-vdl-sources><div class="vdl-src-empty">Loading sources…</div></div>' +
|
||||
|
|
@ -73,10 +72,8 @@
|
|||
'<span class="vdl-src-main"><span class="vdl-src-name">' + esc(m.name) + '</span>' +
|
||||
'<span class="vdl-src-meta"><span class="vdl-src-status" data-vdl-status>Ready</span></span></span>' +
|
||||
'<span class="vdl-src-actions">' +
|
||||
'<button class="vdl-src-search" type="button" data-vdl-search="' + s + '" title="Search and pick a release yourself">' +
|
||||
'<span class="vdl-btn-ic" aria-hidden="true">⌕</span><span>Manual</span></button>' +
|
||||
'<button class="vdl-src-auto" type="button" data-vdl-auto="' + s + '" title="Search and auto-grab the best release for your quality profile">' +
|
||||
'<span class="vdl-btn-ic vdl-btn-ic--auto" aria-hidden="true">✦</span><span>Auto</span></button>' +
|
||||
'<button class="vdl-src-search" type="button" data-vdl-search="' + s + '" title="Search and pick a release yourself">MANUAL</button>' +
|
||||
'<button class="vdl-src-auto" type="button" data-vdl-auto="' + s + '" title="Search and auto-grab the best release for your quality profile">AUTO</button>' +
|
||||
'</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
|
@ -523,42 +520,32 @@
|
|||
// demoted to a mono one-liner; a stat strip (size / uploader / group) sits below.
|
||||
// The card is a column so a live download tracker can drop in under it on grab.
|
||||
function resultCardHTML(r, i) {
|
||||
// Cinematic release CARD: a bold quality badge (resolution over source) anchors
|
||||
// the left, the RELEASE NAME is the hero, the meta is a row of crisp pills, and
|
||||
// size · verdict · Get sit on the right. The outer .vdl-res is a column so the
|
||||
// Flat / brutalist release card: a bracketed quality block + release name on
|
||||
// line 1, an UPPERCASE dot-separated spec line, then the verdict + a hard
|
||||
// [ GET ] button. Monospace, sharp, no chrome. .vdl-res stays a column so the
|
||||
// live download tracker docks under it on grab.
|
||||
var tag = function (t, mod) { return '<span class="vdl-tag' + (mod ? ' vdl-tag--' + mod : '') + '">' + esc(t) + '</span>'; };
|
||||
var tags = [];
|
||||
if (r.codec) tags.push(tag(String(r.codec).toUpperCase()));
|
||||
if (r.audio) tags.push(tag(String(r.audio).toUpperCase().replace('-', ' ')));
|
||||
if (r.hdr) tags.push(tag(String(r.hdr).toUpperCase(), 'hdr'));
|
||||
if (r.repack) tags.push(tag('REPACK', 'rep'));
|
||||
if (r.group) tags.push(tag(r.group, 'grp'));
|
||||
var avail = r.username
|
||||
? '<span class="vdl-avail"><span class="vdl-avail-ic">●</span>' + esc(r.username) + (r.peers > 1 ? ' · ' + r.peers : '') + '</span>'
|
||||
: '<span class="vdl-avail vdl-avail--seed">▲ ' + (r.seeders || 0) + '</span>';
|
||||
var flag = r.accepted
|
||||
? '<span class="vdl-flag vdl-flag--ok" title="Meets your quality profile">✓</span>'
|
||||
: '<span class="vdl-flag vdl-flag--no" title="' + esc(r.rejected || 'Filtered out') + '">✕</span>';
|
||||
var meta = [SRC_LABEL[r.source] || r.source || ''];
|
||||
if (r.codec) meta.push(String(r.codec).toUpperCase());
|
||||
if (r.audio) meta.push(String(r.audio).toUpperCase().replace('-', ' '));
|
||||
if (r.hdr) meta.push(String(r.hdr).toUpperCase());
|
||||
if (r.repack) meta.push('REPACK');
|
||||
meta.push(r.username ? r.username + (r.peers > 1 ? ' (' + r.peers + ')' : '') : (r.seeders || 0) + ' SEED');
|
||||
if (r.group) meta.push(r.group);
|
||||
meta.push(r.size_gb + ' GB');
|
||||
var verdict = r.accepted
|
||||
? '<span class="vdl-r-verdict vdl-r-verdict--ok">✓ MEETS PROFILE</span>'
|
||||
: '<span class="vdl-r-verdict vdl-r-verdict--no" title="' + esc(r.rejected || '') + '">✗ ' + esc((r.rejected || 'FILTERED').toUpperCase()) + '</span>';
|
||||
var grab = (r.accepted && r.username)
|
||||
? '<button class="vdl-res-grab" type="button" data-vdl-grab="' + i + '" title="Download this release">' +
|
||||
'<span class="vdl-res-grab-ic" aria-hidden="true">⤓</span><span>Get</span></button>'
|
||||
? '<button class="vdl-res-grab" type="button" data-vdl-grab="' + i + '" title="Download this release">[ GET ]</button>'
|
||||
: '';
|
||||
var srcWord = SRC_LABEL[r.source] || r.source || '';
|
||||
return '<div class="vdl-res' + (r.accepted ? ' vdl-res--ok' : ' vdl-res--rejected') + '" data-vdl-card="' + i + '">' +
|
||||
'<div class="vdl-res-main">' +
|
||||
'<div class="vdl-q">' +
|
||||
'<span class="vdl-q-res vdl-q-res--' + resKind(r.resolution) + '">' + esc(RES_LABEL[r.resolution] || r.resolution || '?') + '</span>' +
|
||||
(srcWord ? '<span class="vdl-q-src">' + esc(srcWord) + '</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="vdl-info">' +
|
||||
'<div class="vdl-info-title" title="' + esc(r.title) + '">' + esc(r.title) + '</div>' +
|
||||
'<div class="vdl-info-tags">' + tags.join('') + avail + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="vdl-res-right">' +
|
||||
'<span class="vdl-size">' + esc(String(r.size_gb)) + '<span class="vdl-size-u">GB</span></span>' +
|
||||
flag + grab +
|
||||
'<div class="vdl-r-l1">' +
|
||||
'<span class="vdl-r-q vdl-r-q--' + resKind(r.resolution) + '">' + esc(RES_LABEL[r.resolution] || r.resolution || '?') + '</span>' +
|
||||
'<span class="vdl-r-title" title="' + esc(r.title) + '">' + esc(r.title) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="vdl-r-l2">' + esc(meta.filter(Boolean).join(' · ')) + '</div>' +
|
||||
'<div class="vdl-r-l3">' + verdict + grab + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
|
@ -700,7 +687,8 @@
|
|||
'<input type="checkbox" class="vdl-season-cb" data-vdl-season-all="' + sn + '">' +
|
||||
'<span class="vdl-season-name">' + esc(s.title || ('Season ' + sn)) + '</span>' +
|
||||
'<span class="vdl-season-meta" data-vdl-season-meta>' + total + ' eps</span>' +
|
||||
'<button class="vdl-season-search" type="button" data-vdl-season-search="' + sn + '" title="Search this season">⌕</button>' +
|
||||
'<button class="vdl-season-grab" type="button" data-vdl-season-grab="' + sn + '" title="Auto-grab every missing episode in this season, one at a time">Grab season</button>' +
|
||||
'<button class="vdl-season-search" type="button" data-vdl-season-search="' + sn + '" title="Search this season as a pack">⌕</button>' +
|
||||
'<span class="vdl-season-chev" aria-hidden="true">⌄</span>' +
|
||||
'</div>' +
|
||||
'<div class="vdl-season-body">' +
|
||||
|
|
@ -768,7 +756,7 @@
|
|||
var container = e.currentTarget; var st = container._dl; if (!st) return;
|
||||
var grab = e.target.closest('[data-vdl-grab]');
|
||||
if (grab) { doGrab(grab); return; }
|
||||
// Episode-scope search (a source row inside an expanded episode → its own panel).
|
||||
// Episode-scope Manual search (a source row inside an expanded episode).
|
||||
var srch = e.target.closest('[data-vdl-search]');
|
||||
if (srch) {
|
||||
var epEl = srch.closest('.vdl-ep'); if (!epEl) return;
|
||||
|
|
@ -780,6 +768,23 @@
|
|||
[srch.closest('.vdl-src')]);
|
||||
return;
|
||||
}
|
||||
// Episode-scope Auto (search this source, then auto-grab the best) — mirrors the
|
||||
// movie per-source Auto, scoped to the episode.
|
||||
var au = e.target.closest('[data-vdl-auto]');
|
||||
if (au) {
|
||||
var epA = au.closest('.vdl-ep'); if (!epA) return;
|
||||
var pa = (epA.getAttribute('data-vdl-ep') || '').split('_');
|
||||
var sa = au.getAttribute('data-vdl-auto');
|
||||
var resA = au.closest('[data-vdl-src-block]').querySelector('[data-vdl-results-for="' + sa + '"]');
|
||||
var rowA = au.closest('.vdl-src');
|
||||
searchInto(container, resA,
|
||||
{ scope: 'episode', title: st.title, season: +pa[0], episode: +pa[1], source: sa },
|
||||
[rowA], function () { _autoPick(resA, rowA); });
|
||||
return;
|
||||
}
|
||||
// Grab whole season — auto-grab every MISSING episode individually (episode-level).
|
||||
var sg = e.target.closest('[data-vdl-season-grab]');
|
||||
if (sg) { grabSeason(container, st, +sg.getAttribute('data-vdl-season-grab')); return; }
|
||||
// Season-scope search → season PACK.
|
||||
var ss = e.target.closest('[data-vdl-season-search]');
|
||||
if (ss) {
|
||||
|
|
@ -833,6 +838,63 @@
|
|||
panel.innerHTML = '<div class="vdl-ep-srcs">' + srcs.map(function (s) { return srcBlockHTML(s, true); }).join('') + '</div>';
|
||||
}
|
||||
|
||||
// ── Grab whole season (episode level) ──────────────────────────────────────
|
||||
// We DON'T grab a multi-file pack; we run the per-episode auto-grab for every
|
||||
// MISSING episode, throttled, reusing searchInto + _autoPick — so each episode
|
||||
// takes the exact same path a manual per-episode Auto would. A hidden scratch
|
||||
// holds the headless result panels; the grabs surface on the Downloads page.
|
||||
// (Pack-folder grabbing can come later; this covers the common case.)
|
||||
function ensureScratch(container) {
|
||||
var s = container.querySelector('[data-vdl-grab-scratch]');
|
||||
if (!s) {
|
||||
s = document.createElement('div');
|
||||
s.setAttribute('data-vdl-grab-scratch', '');
|
||||
s.style.display = 'none';
|
||||
container.appendChild(s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function autoGrabEpisode(container, st, sn, en, src) {
|
||||
// Resolves once the SEARCH settles (the grab POST fires inside _autoPick);
|
||||
// throttling the searches is what protects slskd from a burst.
|
||||
return new Promise(function (resolve) {
|
||||
var panel = document.createElement('div');
|
||||
panel.className = 'vdl-results vdl-res-noanim';
|
||||
ensureScratch(container).appendChild(panel);
|
||||
searchInto(container, panel,
|
||||
{ scope: 'episode', title: st.title, season: sn, episode: en, source: src },
|
||||
[], function () { _autoPick(panel, null); resolve(); });
|
||||
});
|
||||
}
|
||||
|
||||
function grabSeason(container, st, sn) {
|
||||
var src = (st.sources || []).filter(function (s) { return SRC_META[s]; })[0];
|
||||
if (!src) { toast('No download source configured', 'error'); return; }
|
||||
var eps = [];
|
||||
for (var k in st.epMeta) {
|
||||
if (k.indexOf(sn + '_') === 0 && st.epMeta[k].state === 'missing') eps.push(+k.split('_')[1]);
|
||||
}
|
||||
eps.sort(function (a, b) { return a - b; });
|
||||
if (!eps.length) { toast('No missing episodes in this season', 'info'); return; }
|
||||
var btn = container.querySelector('[data-vdl-season-grab="' + sn + '"]');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Grabbing 0/' + eps.length; }
|
||||
toast('Auto-grabbing ' + eps.length + ' missing episode' + (eps.length > 1 ? 's' : '') + ' — watch Downloads', 'info');
|
||||
var idx = 0, active = 0, done = 0, MAX = 3;
|
||||
function pump() {
|
||||
while (active < MAX && idx < eps.length) {
|
||||
active++;
|
||||
autoGrabEpisode(container, st, sn, eps[idx++], src).then(function () {
|
||||
active--; done++;
|
||||
if (btn) btn.textContent = done < eps.length ? 'Grabbing ' + done + '/' + eps.length : 'Season queued';
|
||||
if (done >= eps.length) { if (btn) btn.disabled = false; }
|
||||
else pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
pump();
|
||||
}
|
||||
|
||||
function setSeasonSel(container, sn, on) {
|
||||
var st = container._dl;
|
||||
var cbs = container.querySelectorAll('.vdl-season[data-vdl-season="' + sn + '"] .vdl-ep-cb');
|
||||
|
|
|
|||
337
webui/static/video/video-import.js
Normal file
337
webui/static/video/video-import.js
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
/*
|
||||
* SoulSync — Video Import page (isolated).
|
||||
*
|
||||
* Mirrors the music Import page's job for the video side: a "Needs Attention" queue
|
||||
* of downloads that finished but couldn't be auto-placed (sample / wrong episode /
|
||||
* not-an-upgrade / corrupt / parse fail). Each one is resolved by HAND — pick the
|
||||
* right movie or show+episode (library/owned results float to the top, falling back
|
||||
* to a full TMDB search) and place it, or dismiss it.
|
||||
*
|
||||
* Reads /api/video/import/failed; resolves via /import/<id>/place + /dismiss; the
|
||||
* identity picker reuses /api/video/search. Polls every 5s while shown, like the
|
||||
* music page. Self-contained IIFE, no globals.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var PAGE_ID = 'video-import';
|
||||
var POLL_MS = 5000;
|
||||
var state = { loaded: false, items: [], resolve: null };
|
||||
var pollTimer = null;
|
||||
var searchTimer = null;
|
||||
|
||||
function $(s, r) { return (r || document).querySelector(s); }
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
function basename(p) { return String(p || '').replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop(); }
|
||||
function toast(msg, kind) { if (typeof showToast === 'function') showToast(msg, kind || 'info'); }
|
||||
function isShown() { return document.body.getAttribute('data-video-page') === PAGE_ID; }
|
||||
|
||||
// ── needs-attention list ──────────────────────────────────────────────────
|
||||
function load() {
|
||||
fetch('/api/video/import/failed', { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
state.loaded = true;
|
||||
state.items = (d && d.items) || [];
|
||||
render();
|
||||
})
|
||||
.catch(function () { state.loaded = true; render(); });
|
||||
}
|
||||
|
||||
function card(it) {
|
||||
var scopeLabel = it.scope === 'episode' || it.kind === 'show' ? 'Episode' : 'Movie';
|
||||
var sub = [scopeLabel, it.year || null].filter(Boolean).join(' · ');
|
||||
return '<div class="vimp-card" data-vimp-card="' + esc(it.id) + '">' +
|
||||
'<div class="vimp-card-main">' +
|
||||
'<div class="vimp-card-title" title="' + esc(it.title || it.release_title) + '">' +
|
||||
esc(it.title || it.release_title || 'Unknown') + '</div>' +
|
||||
'<div class="vimp-card-file" title="' + esc(it.file) + '">' + esc(basename(it.file) || '—') + '</div>' +
|
||||
'<div class="vimp-card-reason">' + esc(it.reason || 'Needs manual import') + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="vimp-card-side">' +
|
||||
(sub ? '<span class="vimp-card-kind">' + esc(sub) + '</span>' : '') +
|
||||
'<div class="vimp-card-actions">' +
|
||||
'<button class="vimp-btn vimp-btn--place" type="button" data-vimp-place="' + esc(it.id) + '">Place…</button>' +
|
||||
'<button class="vimp-btn vimp-btn--dismiss" type="button" data-vimp-dismiss="' + esc(it.id) + '">Dismiss</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
var grid = $('[data-vimp-grid]');
|
||||
var loading = $('[data-vimp-loading]');
|
||||
var empty = $('[data-vimp-empty]');
|
||||
var count = $('[data-vimp-count]');
|
||||
if (!grid) return;
|
||||
if (loading) loading.classList.toggle('hidden', state.loaded);
|
||||
if (count) count.textContent = state.items.length ? String(state.items.length) : '';
|
||||
if (!state.loaded) { grid.innerHTML = ''; return; }
|
||||
if (!state.items.length) {
|
||||
grid.innerHTML = '';
|
||||
if (empty) empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
if (empty) empty.classList.add('hidden');
|
||||
grid.innerHTML = state.items.map(card).join('');
|
||||
}
|
||||
|
||||
function itemById(id) {
|
||||
for (var i = 0; i < state.items.length; i++)
|
||||
if (String(state.items[i].id) === String(id)) return state.items[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── resolve modal ─────────────────────────────────────────────────────────
|
||||
function openResolve(item) {
|
||||
state.resolve = {
|
||||
item: item,
|
||||
kind: (item.scope === 'episode' || item.kind === 'show') ? 'episode' : 'movie',
|
||||
query: item.title || basename(item.file) || '',
|
||||
results: [], picked: null, season: item.season || '', episode: item.episode || '',
|
||||
searching: false,
|
||||
};
|
||||
ensureModal();
|
||||
renderModal();
|
||||
runSearch();
|
||||
var input = $('[data-vimp-q]');
|
||||
if (input) { input.value = state.resolve.query; input.focus(); }
|
||||
}
|
||||
|
||||
function closeResolve() {
|
||||
state.resolve = null;
|
||||
var m = $('[data-vimp-modal]');
|
||||
if (m) m.remove();
|
||||
}
|
||||
|
||||
function ensureModal() {
|
||||
if ($('[data-vimp-modal]')) return;
|
||||
var m = document.createElement('div');
|
||||
m.className = 'vimp-modal';
|
||||
m.setAttribute('data-vimp-modal', '');
|
||||
m.innerHTML =
|
||||
'<div class="vimp-modal-scrim" data-vimp-close></div>' +
|
||||
'<div class="vimp-modal-card" role="dialog" aria-label="Place file">' +
|
||||
'<div class="vimp-modal-head">' +
|
||||
'<div class="vimp-modal-titles">' +
|
||||
'<h2 class="vimp-modal-title">Place this file</h2>' +
|
||||
'<div class="vimp-modal-file" data-vimp-modal-file></div>' +
|
||||
'</div>' +
|
||||
'<button class="vimp-modal-x" type="button" data-vimp-close aria-label="Close">×</button>' +
|
||||
'</div>' +
|
||||
'<div class="vimp-kindtabs" data-vimp-kindtabs>' +
|
||||
'<button class="vimp-kindtab" type="button" data-vimp-kind="movie">Movie</button>' +
|
||||
'<button class="vimp-kindtab" type="button" data-vimp-kind="episode">Episode</button>' +
|
||||
'</div>' +
|
||||
'<div class="vimp-search">' +
|
||||
'<input type="text" class="vimp-search-input" data-vimp-q placeholder="Search your library & TMDB…" autocomplete="off" spellcheck="false">' +
|
||||
'</div>' +
|
||||
'<div class="vimp-results" data-vimp-results></div>' +
|
||||
'<div class="vimp-ep" data-vimp-ep hidden>' +
|
||||
'<label class="vimp-ep-field">Season <input type="number" min="0" data-vimp-season></label>' +
|
||||
'<label class="vimp-ep-field">Episode <input type="number" min="0" data-vimp-episode></label>' +
|
||||
'<label class="vimp-ep-field vimp-ep-field--wide">Title <input type="text" data-vimp-eptitle placeholder="optional"></label>' +
|
||||
'</div>' +
|
||||
'<div class="vimp-modal-foot">' +
|
||||
'<button class="vimp-btn vimp-btn--ghost" type="button" data-vimp-close>Cancel</button>' +
|
||||
'<button class="vimp-btn vimp-btn--place" type="button" data-vimp-confirm disabled>Place file</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(m);
|
||||
}
|
||||
|
||||
function renderModal() {
|
||||
var r = state.resolve;
|
||||
if (!r) return;
|
||||
var fileEl = $('[data-vimp-modal-file]');
|
||||
if (fileEl) fileEl.textContent = basename(r.item.file) + ' — ' + (r.item.reason || '');
|
||||
var tabs = document.querySelectorAll('[data-vimp-kind]');
|
||||
for (var i = 0; i < tabs.length; i++)
|
||||
tabs[i].classList.toggle('vimp-kindtab--on', tabs[i].getAttribute('data-vimp-kind') === r.kind);
|
||||
var ep = $('[data-vimp-ep]');
|
||||
if (ep) ep.hidden = !(r.kind === 'episode' && r.picked);
|
||||
var sEl = $('[data-vimp-season]'); if (sEl && r.season !== '') sEl.value = r.season;
|
||||
var eEl = $('[data-vimp-episode]'); if (eEl && r.episode !== '') eEl.value = r.episode;
|
||||
renderResults();
|
||||
updateConfirm();
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
var box = $('[data-vimp-results]');
|
||||
var r = state.resolve;
|
||||
if (!box || !r) return;
|
||||
if (r.searching) { box.innerHTML = '<div class="vimp-res-note">Searching…</div>'; return; }
|
||||
if (!r.results.length) { box.innerHTML = '<div class="vimp-res-note">No matches — try a different search.</div>'; return; }
|
||||
box.innerHTML = r.results.map(function (it, idx) {
|
||||
var on = r.picked && String(r.picked.media_id) === String(it.media_id);
|
||||
var meta = [it.year, it.owned ? 'In library' : null].filter(Boolean).join(' · ');
|
||||
var art = it.poster
|
||||
? '<img class="vimp-res-img" src="' + esc(it.poster) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
|
||||
: '<div class="vimp-res-ph">' + (r.kind === 'episode' ? '📺' : '🎬') + '</div>';
|
||||
return '<button class="vimp-res' + (on ? ' vimp-res--on' : '') + (it.owned ? ' vimp-res--owned' : '') +
|
||||
'" type="button" data-vimp-pick="' + idx + '">' + art +
|
||||
'<span class="vimp-res-info"><span class="vimp-res-title">' + esc(it.title) + '</span>' +
|
||||
(meta ? '<span class="vimp-res-meta">' + esc(meta) + '</span>' : '') + '</span></button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateConfirm() {
|
||||
var btn = $('[data-vimp-confirm]');
|
||||
var r = state.resolve;
|
||||
if (!btn || !r) return;
|
||||
var ok = !!r.picked && (r.kind === 'movie' ||
|
||||
(r.kind === 'episode' && r.season !== '' && r.episode !== ''));
|
||||
btn.disabled = !ok;
|
||||
}
|
||||
|
||||
// Normalise a /api/video/search result into the picker's shape; keep only the
|
||||
// kind we're resolving (movies for 'movie', shows for 'episode'). Owned titles
|
||||
// (library_id present) are flagged so they can float to the top.
|
||||
function normResults(raw, kind) {
|
||||
var want = kind === 'episode' ? ['tv', 'show'] : ['movie'];
|
||||
var out = [];
|
||||
(raw || []).forEach(function (it) {
|
||||
var mt = String(it.media_type || it.type || (it.first_air_date ? 'tv' : 'movie')).toLowerCase();
|
||||
if (want.indexOf(mt) === -1) return;
|
||||
var date = it.year || it.release_date || it.first_air_date || '';
|
||||
out.push({
|
||||
media_id: it.tmdb_id != null ? it.tmdb_id : it.id,
|
||||
title: it.title || it.name || 'Unknown',
|
||||
year: String(date).slice(0, 4) || null,
|
||||
poster: it.poster_url || it.poster || (it.poster_path ? 'https://image.tmdb.org/t/p/w185' + it.poster_path : ''),
|
||||
owned: it.library_id != null,
|
||||
});
|
||||
});
|
||||
out.sort(function (a, b) { return (b.owned ? 1 : 0) - (a.owned ? 1 : 0); }); // library first
|
||||
return out;
|
||||
}
|
||||
|
||||
function runSearch() {
|
||||
var r = state.resolve;
|
||||
if (!r) return;
|
||||
var q = (r.query || '').trim();
|
||||
if (!q) { r.results = []; r.searching = false; renderResults(); return; }
|
||||
r.searching = true; renderResults();
|
||||
fetch('/api/video/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
|
||||
.then(function (res) { return res.ok ? res.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!state.resolve || state.resolve !== r) return;
|
||||
r.searching = false;
|
||||
r.results = normResults((d && d.results) || [], r.kind);
|
||||
renderResults();
|
||||
})
|
||||
.catch(function () { if (state.resolve === r) { r.searching = false; renderResults(); } });
|
||||
}
|
||||
|
||||
function place() {
|
||||
var r = state.resolve;
|
||||
if (!r || !r.picked) return;
|
||||
var body = {
|
||||
scope: r.kind, media_id: r.picked.media_id,
|
||||
title: r.picked.title, year: r.picked.year ? parseInt(r.picked.year, 10) : null,
|
||||
};
|
||||
if (r.kind === 'episode') {
|
||||
body.season = parseInt(r.season, 10);
|
||||
body.episode = parseInt(r.episode, 10);
|
||||
var t = $('[data-vimp-eptitle]'); if (t && t.value.trim()) body.episode_title = t.value.trim();
|
||||
}
|
||||
var btn = $('[data-vimp-confirm]'); if (btn) { btn.disabled = true; btn.textContent = 'Placing…'; }
|
||||
fetch('/api/video/import/' + r.item.id + '/place', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then(function (res) { return res.ok ? res.json() : res.json().catch(function () { return null; }); })
|
||||
.then(function (d) {
|
||||
if (d && d.success) { toast('Placed “' + r.picked.title + '”', 'success'); closeResolve(); load(); }
|
||||
else { toast((d && d.error) || 'Couldn’t place the file', 'error');
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Place file'; } }
|
||||
})
|
||||
.catch(function () { toast('Couldn’t place the file', 'error');
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Place file'; } });
|
||||
}
|
||||
|
||||
function dismiss(id) {
|
||||
var it = itemById(id);
|
||||
var go = function (del) {
|
||||
fetch('/api/video/import/' + id + '/dismiss', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ delete_file: !!del }),
|
||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) { if (d && d.success) { toast('Dismissed', 'info'); load(); }
|
||||
else toast('Couldn’t dismiss', 'error'); })
|
||||
.catch(function () { toast('Couldn’t dismiss', 'error'); });
|
||||
};
|
||||
if (typeof showConfirmDialog === 'function') {
|
||||
showConfirmDialog({
|
||||
title: 'Dismiss this import',
|
||||
message: 'Remove “' + ((it && it.title) || basename(it && it.file)) + '” from the list? ' +
|
||||
'The file stays on disk unless you choose to delete it.',
|
||||
confirmText: 'Dismiss', cancelText: 'Cancel',
|
||||
}).then(function (ok) { if (ok) go(false); });
|
||||
} else if (window.confirm('Dismiss this import?')) { go(false); }
|
||||
}
|
||||
|
||||
// ── events ────────────────────────────────────────────────────────────────
|
||||
function onGridClick(e) {
|
||||
var p = e.target.closest('[data-vimp-place]');
|
||||
if (p) { var it = itemById(p.getAttribute('data-vimp-place')); if (it) openResolve(it); return; }
|
||||
var d = e.target.closest('[data-vimp-dismiss]');
|
||||
if (d) { dismiss(d.getAttribute('data-vimp-dismiss')); return; }
|
||||
}
|
||||
|
||||
function onModalClick(e) {
|
||||
if (e.target.closest('[data-vimp-close]')) { closeResolve(); return; }
|
||||
var k = e.target.closest('[data-vimp-kind]');
|
||||
if (k) { state.resolve.kind = k.getAttribute('data-vimp-kind'); state.resolve.picked = null;
|
||||
runSearch(); renderModal(); return; }
|
||||
var pk = e.target.closest('[data-vimp-pick]');
|
||||
if (pk) { var r = state.resolve;
|
||||
r.picked = r.results[parseInt(pk.getAttribute('data-vimp-pick'), 10)] || null;
|
||||
renderModal(); return; }
|
||||
}
|
||||
|
||||
function onModalInput(e) {
|
||||
var r = state.resolve; if (!r) return;
|
||||
if (e.target.matches('[data-vimp-q]')) {
|
||||
r.query = e.target.value;
|
||||
clearTimeout(searchTimer); searchTimer = setTimeout(runSearch, 300); return;
|
||||
}
|
||||
if (e.target.matches('[data-vimp-season]')) { r.season = e.target.value; updateConfirm(); return; }
|
||||
if (e.target.matches('[data-vimp-episode]')) { r.episode = e.target.value; updateConfirm(); return; }
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
if (pollTimer) return;
|
||||
pollTimer = setInterval(function () { if (isShown() && !state.resolve) load(); }, POLL_MS);
|
||||
}
|
||||
|
||||
function onShown(e) {
|
||||
if (e && e.detail !== PAGE_ID) return;
|
||||
load();
|
||||
startPoll();
|
||||
}
|
||||
|
||||
function init() {
|
||||
var grid = $('[data-vimp-grid]');
|
||||
if (grid) grid.addEventListener('click', onGridClick);
|
||||
var refresh = $('[data-vimp-refresh]');
|
||||
if (refresh) refresh.addEventListener('click', load);
|
||||
// The resolve modal is created on demand; delegate from the document.
|
||||
document.addEventListener('click', function (e) {
|
||||
if (state.resolve && e.target.closest('[data-vimp-modal]')) {
|
||||
if (e.target.closest('[data-vimp-confirm]')) { place(); return; }
|
||||
onModalClick(e);
|
||||
}
|
||||
});
|
||||
document.addEventListener('input', function (e) {
|
||||
if (state.resolve && e.target.closest('[data-vimp-modal]')) onModalInput(e);
|
||||
});
|
||||
document.addEventListener('soulsync:video-page-shown', onShown);
|
||||
if (isShown()) onShown({ detail: PAGE_ID });
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||
else init();
|
||||
})();
|
||||
|
|
@ -685,6 +685,117 @@
|
|||
}).catch(function () { toast('Failed to test ' + name + ' connection', 'error'); });
|
||||
}
|
||||
|
||||
// ── Library Organization (naming templates + post-process toggles) ───────
|
||||
var ORG_URL = '/api/video/organization';
|
||||
var _videoOrg = null;
|
||||
// Sample values for the live preview (mirrors what the importer feeds the engine).
|
||||
var _ORG_MOVIE_EG = { title: 'The Matrix', titlefirst: 'T', year: '1999', quality: 'Bluray-1080p',
|
||||
resolution: '1080p', source: 'Bluray', codec: 'HEVC', edition: '', tmdbid: '603', imdbid: 'tt0133093' };
|
||||
var _ORG_EP_EG = { series: 'Breaking Bad', season: '01', seasonraw: '1', episode: '01', episodetitle: 'Pilot',
|
||||
year: '2008', quality: 'WEBDL-1080p', resolution: '1080p', source: 'WEBDL', codec: 'H264', tvdbid: '81189' };
|
||||
|
||||
function _orgSanitize(v) {
|
||||
return String(v == null ? '' : v).replace(/[\\/:*?"<>|\x00-\x1f]/g, '')
|
||||
.replace(/\s+/g, ' ').trim().replace(/[ .]+$/, '');
|
||||
}
|
||||
// Client-side mirror of core/video/organization.render_template + _tidy_component
|
||||
// (kept in lockstep) so the preview matches what actually lands on disk.
|
||||
function _orgRender(tmpl, vals) {
|
||||
var keys = Object.keys(vals).sort(function (a, b) { return b.length - a.length; });
|
||||
var out = String(tmpl || '');
|
||||
keys.forEach(function (k) { out = out.split('${' + k + '}').join(_orgSanitize(vals[k])); });
|
||||
keys.forEach(function (k) { out = out.split('$' + k).join(_orgSanitize(vals[k])); });
|
||||
return out.split('/').map(function (p) {
|
||||
p = p.replace(/\s+-\s+(?=(\s|$))/g, ' ').replace(/\(\s*\)/g, '').replace(/\[\s*\]/g, '');
|
||||
p = p.replace(/\s+/g, ' ').trim().replace(/^-+|-+$/g, '').trim().replace(/[ .]+$/, '');
|
||||
return p;
|
||||
}).filter(function (p) { return p !== ''; }).join('/');
|
||||
}
|
||||
function renderOrgPreview() {
|
||||
var mt = document.getElementById('vo-movie-template');
|
||||
var et = document.getElementById('vo-episode-template');
|
||||
var mp = document.getElementById('vo-movie-preview');
|
||||
var ep = document.getElementById('vo-episode-preview');
|
||||
if (mp && mt) mp.textContent = _orgRender(mt.value || mt.placeholder, _ORG_MOVIE_EG) + '.mkv';
|
||||
if (ep && et) ep.textContent = _orgRender(et.value || et.placeholder, _ORG_EP_EG) + '.mkv';
|
||||
}
|
||||
function fillOrg() {
|
||||
if (!_videoOrg) return;
|
||||
var set = function (id, v) { var el = document.getElementById(id); if (el) el.value = v; };
|
||||
var chk = function (id, v) { var el = document.getElementById(id); if (el) el.checked = !!v; };
|
||||
set('vo-movie-template', _videoOrg.movie_template || '');
|
||||
set('vo-episode-template', _videoOrg.episode_template || '');
|
||||
set('vo-transfer-mode', _videoOrg.transfer_mode || 'copy');
|
||||
chk('vo-verify', _videoOrg.verify_with_ffprobe);
|
||||
chk('vo-replace', _videoOrg.replace_existing);
|
||||
chk('vo-subs', _videoOrg.carry_subtitles);
|
||||
chk('vo-artwork', _videoOrg.save_artwork);
|
||||
chk('vo-nfo', _videoOrg.write_nfo);
|
||||
chk('vo-subs-dl', _videoOrg.download_subtitles);
|
||||
set('vo-sub-langs', _videoOrg.subtitle_langs || 'en');
|
||||
renderOrgPreview();
|
||||
}
|
||||
function loadOrganization() {
|
||||
fetch(ORG_URL, { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) { if (d) { _videoOrg = d; fillOrg(); } })
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
function collectOrg() {
|
||||
var val = function (id) { var el = document.getElementById(id); return el ? el.value : ''; };
|
||||
var on = function (id) { var el = document.getElementById(id); return !!(el && el.checked); };
|
||||
return {
|
||||
movie_template: val('vo-movie-template'),
|
||||
episode_template: val('vo-episode-template'),
|
||||
transfer_mode: val('vo-transfer-mode'),
|
||||
verify_with_ffprobe: on('vo-verify'),
|
||||
replace_existing: on('vo-replace'),
|
||||
carry_subtitles: on('vo-subs'),
|
||||
save_artwork: on('vo-artwork'),
|
||||
write_nfo: on('vo-nfo'),
|
||||
download_subtitles: on('vo-subs-dl'),
|
||||
subtitle_langs: val('vo-sub-langs')
|
||||
};
|
||||
}
|
||||
function saveOrganization(silent) {
|
||||
return fetch(ORG_URL, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(collectOrg())
|
||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) { if (d) { _videoOrg = d; } if (!silent) toast('Library organization saved', 'success'); })
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
function wireOrganization() {
|
||||
var anchor = document.getElementById('vo-movie-template');
|
||||
if (!anchor) return;
|
||||
var card = anchor.closest('.settings-group');
|
||||
if (!card || card._voWired) return;
|
||||
card._voWired = true;
|
||||
['vo-movie-template', 'vo-episode-template'].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.addEventListener('input', renderOrgPreview); // live preview while typing
|
||||
el.addEventListener('change', function () { saveOrganization(false); });
|
||||
});
|
||||
['vo-transfer-mode', 'vo-verify', 'vo-replace', 'vo-subs', 'vo-artwork', 'vo-nfo',
|
||||
'vo-subs-dl', 'vo-sub-langs'].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.addEventListener('change', function () { saveOrganization(false); });
|
||||
});
|
||||
var reset = document.getElementById('vo-reset');
|
||||
if (reset) reset.addEventListener('click', function () {
|
||||
// POST blank templates + standard toggles; the backend normalises to the
|
||||
// Radarr/Sonarr defaults and echoes them back.
|
||||
fetch(ORG_URL, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ movie_template: '', episode_template: '', transfer_mode: 'copy',
|
||||
verify_with_ffprobe: true, replace_existing: true, carry_subtitles: true,
|
||||
save_artwork: false, write_nfo: false, download_subtitles: false, subtitle_langs: 'en' })
|
||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) { if (d) { _videoOrg = d; fillOrg(); toast('Reset to the standard layout', 'success'); } });
|
||||
});
|
||||
}
|
||||
|
||||
function onPageShown(e) {
|
||||
if (e && e.detail !== PAGE_ID) return;
|
||||
loadServer();
|
||||
|
|
@ -699,6 +810,8 @@
|
|||
wireYtQuality();
|
||||
loadSlskd();
|
||||
wireSlskd();
|
||||
loadOrganization();
|
||||
wireOrganization();
|
||||
}
|
||||
|
||||
function init() {
|
||||
|
|
|
|||
|
|
@ -3107,107 +3107,79 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vdl-reason { font-size: 12px; font-weight: 600; }
|
||||
.vdl-reason--ok { color: rgba(143, 231, 175, 0.85); }
|
||||
.vdl-reason--no { color: rgba(251, 205, 122, 0.92); }
|
||||
/* ── sources: redesigned ──────────────────────────────────────────────────
|
||||
Header "all" buttons share the same Manual(secondary)/Auto(hero) language as
|
||||
the per-source pair — Manual = quiet ghost, Auto = filled + soft glow + sheen. */
|
||||
.vdl-search-all { position: relative; overflow: hidden; display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 7px 13px; border-radius: 10px; cursor: pointer; font-size: 12px; font-weight: 800; letter-spacing: 0.01em;
|
||||
color: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14); transition: background 0.16s, border-color 0.16s, transform 0.16s, box-shadow 0.18s; }
|
||||
.vdl-search-all:hover { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.28);
|
||||
transform: translateY(-1px); box-shadow: 0 7px 18px -10px rgba(0, 0, 0, 0.8); }
|
||||
.vdl-search-all:disabled { opacity: 0.5; cursor: default; transform: none; box-shadow: none; }
|
||||
/* Auto all = the hero header action, in the page accent */
|
||||
.vdl-auto-all { --glow: var(--accent-rgb); color: #06210f; border-color: transparent;
|
||||
background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.72));
|
||||
box-shadow: 0 5px 16px -7px rgb(var(--accent-rgb)), inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
animation: vdlAutoGlow 3s ease-in-out infinite; }
|
||||
.vdl-auto-all:hover { color: #06210f; background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.82));
|
||||
filter: brightness(1.05); }
|
||||
.vdl-auto-all::after { content: ''; position: absolute; inset: 0; transform: translateX(-130%);
|
||||
background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.55) 50%, transparent 65%); }
|
||||
.vdl-auto-all:hover::after { animation: vdlSheen 0.7s ease 1; }
|
||||
/* ── sources: FLAT / BRUTALIST ────────────────────────────────────────────
|
||||
Sharp corners, hard 1px borders, flat colour, monospace, UPPERCASE. No
|
||||
gradients, no glow, no rounded pills. Tool-like and confident.
|
||||
Shared mono stack: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace */
|
||||
.vdl-search-all { display: inline-flex; align-items: center; padding: 7px 15px; border-radius: 0; cursor: pointer;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.85); background: transparent; border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s; }
|
||||
.vdl-search-all:hover { background: rgba(255, 255, 255, 0.1); color: #fff; border-color: rgba(255, 255, 255, 0.4); }
|
||||
.vdl-search-all:disabled { opacity: 0.4; cursor: default; }
|
||||
/* Auto all = the one filled action — flat solid accent, inverts on hover */
|
||||
.vdl-auto-all { color: #08111f; background: rgb(var(--accent-rgb)); border-color: rgb(var(--accent-rgb)); }
|
||||
.vdl-auto-all:hover { color: rgb(var(--accent-rgb)); background: transparent; border-color: rgb(var(--accent-rgb)); }
|
||||
|
||||
.vdl-sources { display: flex; flex-direction: column; gap: 10px; }
|
||||
/* a source = a sleek dark-glass row; the brand colour is an ACCENT (icon + edge
|
||||
light + hover wash), not a full colour wash — calmer + more premium. */
|
||||
.vdl-src { --src: 130 130 150; position: relative; overflow: hidden; display: flex; align-items: center; gap: 14px;
|
||||
padding: 12px 13px; border-radius: 14px; border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.02));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); animation: vdlSlide 0.46s both;
|
||||
transition: transform 0.2s cubic-bezier(0.2, 0.7, 0.2, 1), border-color 0.2s, box-shadow 0.25s, background 0.2s; }
|
||||
/* a thin brand light on the left edge — extends + brightens on hover */
|
||||
.vdl-src::before { content: ''; position: absolute; left: 0; top: 16%; bottom: 16%; width: 3px; border-radius: 3px;
|
||||
background: rgb(var(--src)); opacity: 0.5; box-shadow: 0 0 12px 1px rgba(var(--src), 0.55);
|
||||
transition: opacity 0.2s, top 0.2s, bottom 0.2s; }
|
||||
.vdl-sources { display: flex; flex-direction: column; gap: 7px; }
|
||||
/* a source = a flat sharp row with a hard brand left-bar; hover does a hard
|
||||
2px shift + brand border (no lift, no shadow, no gradient). */
|
||||
.vdl-src { --src: 130 130 150; position: relative; display: flex; align-items: center; gap: 12px;
|
||||
padding: 11px 13px; border-radius: 0; border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-left: 3px solid rgb(var(--src)); background: rgba(255, 255, 255, 0.018);
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
transition: background 0.12s, border-color 0.12s, transform 0.12s; }
|
||||
.vdl-src[data-vdl-src="soulseek"] { --src: 74 163 255; }
|
||||
.vdl-src[data-vdl-src="torrent"] { --src: 245 158 11; }
|
||||
.vdl-src[data-vdl-src="usenet"] { --src: 168 85 247; }
|
||||
.vdl-src[data-vdl-src="youtube"] { --src: 255 64 64; }
|
||||
.vdl-src:hover { transform: translateY(-2px); border-color: rgba(var(--src), 0.42);
|
||||
background: linear-gradient(180deg, rgba(var(--src), 0.07), rgba(255, 255, 255, 0.015));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 16px 34px -18px rgba(var(--src), 0.85); }
|
||||
.vdl-src:hover::before { top: 0; bottom: 0; opacity: 1; }
|
||||
/* brand icon tile — glassy, with an inner highlight + brand halo */
|
||||
.vdl-src-icon { position: relative; flex-shrink: 0; width: 44px; height: 44px; border-radius: 13px; display: grid; place-items: center;
|
||||
background: linear-gradient(150deg, rgba(var(--src), 0.42), rgba(var(--src), 0.1));
|
||||
border: 1px solid rgba(var(--src), 0.42);
|
||||
box-shadow: 0 7px 20px -9px rgb(var(--src)), inset 0 1px 0 rgba(255, 255, 255, 0.25); transition: transform 0.25s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
.vdl-src:hover .vdl-src-icon { transform: translateY(-1px) scale(1.05); }
|
||||
.vdl-src-emoji { font-size: 20px; line-height: 1; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4)); }
|
||||
.vdl-src:hover { transform: translateX(2px); border-color: rgba(var(--src), 0.55);
|
||||
border-left-color: rgb(var(--src)); background: rgba(var(--src), 0.06); }
|
||||
/* brand icon tile — flat sharp square, hard brand border */
|
||||
.vdl-src-icon { flex-shrink: 0; width: 36px; height: 36px; border-radius: 0; display: grid; place-items: center;
|
||||
background: rgba(var(--src), 0.16); border: 1px solid rgba(var(--src), 0.5); }
|
||||
.vdl-src-emoji { font-size: 17px; line-height: 1; }
|
||||
.vdl-src-main { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 0; }
|
||||
.vdl-src-name { font-size: 15px; font-weight: 800; color: #fff; letter-spacing: -0.01em; }
|
||||
.vdl-src-name { font-size: 13px; font-weight: 700; color: #fff; text-transform: uppercase; letter-spacing: 0.07em; }
|
||||
.vdl-src-meta { display: inline-flex; align-items: center; }
|
||||
/* status is now a brand-tinted pill (was bare text) so the row reads "designed" */
|
||||
.vdl-src-status { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px 3px 8px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 800; letter-spacing: 0.01em; color: rgba(255, 255, 255, 0.62);
|
||||
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.09); white-space: nowrap; }
|
||||
.vdl-src-status::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: rgb(var(--src));
|
||||
box-shadow: 0 0 8px 0 rgba(var(--src), 0.9); animation: vdlDot 2.2s ease-in-out infinite; }
|
||||
/* status = flat sharp mono chip with a hard square dot (no glow) */
|
||||
.vdl-src-status { display: inline-flex; align-items: center; gap: 6px; padding: 2px 8px; border-radius: 0;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(255, 255, 255, 0.5);
|
||||
background: transparent; border: 1px solid rgba(255, 255, 255, 0.13); white-space: nowrap; }
|
||||
.vdl-src-status::before { content: ''; width: 6px; height: 6px; border-radius: 0; background: rgb(var(--src));
|
||||
animation: vdlDot 2.2s ease-in-out infinite; }
|
||||
.vdl-src--scanning .vdl-src-status::before { animation-duration: 0.7s; }
|
||||
.vdl-src-status--soon { color: #fbcd7a; }
|
||||
.vdl-src-status--scanning { color: rgb(var(--src)); background: rgba(var(--src), 0.14); border-color: rgba(var(--src), 0.3); }
|
||||
.vdl-src-status--soon { color: #fbcd7a; border-color: rgba(245, 158, 11, 0.4); }
|
||||
.vdl-src-status--scanning { color: rgb(var(--src)); border-color: rgba(var(--src), 0.5); }
|
||||
.vdl-src-status--scanning::after { content: ''; animation: vdlDots 1.1s steps(1) infinite; }
|
||||
.vdl-src-status--done { color: #6cd391; background: rgba(108, 211, 145, 0.14); border-color: rgba(108, 211, 145, 0.32); }
|
||||
.vdl-src-status--done::before { background: #6cd391; box-shadow: 0 0 8px 0 rgba(108, 211, 145, 0.9); animation: none; }
|
||||
.vdl-src-status--none { color: #fbcd7a; background: rgba(245, 158, 11, 0.13); border-color: rgba(245, 158, 11, 0.32); }
|
||||
.vdl-src-status--none::before { background: #fbcd7a; box-shadow: none; animation: none; }
|
||||
/* legacy dot element is folded into the status pill now */
|
||||
.vdl-src-status--done { color: #6cd391; border-color: rgba(108, 211, 145, 0.5); }
|
||||
.vdl-src-status--done::before { background: #6cd391; animation: none; }
|
||||
.vdl-src-status--none { color: #fbcd7a; border-color: rgba(245, 158, 11, 0.5); }
|
||||
.vdl-src-status--none::before { background: #fbcd7a; animation: none; }
|
||||
.vdl-src-dot { display: none; }
|
||||
/* moving scan bar along the bottom of a row while "searching" — in the source brand */
|
||||
.vdl-src--scanning::after { content: ''; position: absolute; left: 0; bottom: 0; height: 2px; width: 38%;
|
||||
border-radius: 2px; background: linear-gradient(90deg, transparent, rgb(var(--src)), transparent);
|
||||
box-shadow: 0 0 10px 1px rgba(var(--src), 0.7); animation: vdlScan 0.9s ease-in-out infinite; }
|
||||
/* hard brand scan bar along the bottom while "searching" — flat, no glow */
|
||||
.vdl-src--scanning::after { content: ''; position: absolute; left: 0; bottom: -1px; height: 2px; width: 38%;
|
||||
background: rgb(var(--src)); animation: vdlScan 0.9s ease-in-out infinite; }
|
||||
|
||||
/* the two per-source actions: Manual = quiet ghost, Auto = hero (brand-filled,
|
||||
soft continuous glow, sheen sweep on hover). Matched height + radius. */
|
||||
.vdl-src-actions { flex-shrink: 0; display: inline-flex; align-items: center; gap: 8px; }
|
||||
.vdl-btn-ic { font-size: 12.5px; line-height: 1; opacity: 0.95; }
|
||||
.vdl-btn-ic--auto { font-size: 13px; }
|
||||
.vdl-src-auto:hover .vdl-btn-ic--auto { animation: vdlSparkle 0.6s ease 1; }
|
||||
.vdl-src-search { flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; padding: 8px 13px; border-radius: 10px;
|
||||
cursor: pointer; font-size: 12px; font-weight: 800; letter-spacing: 0.01em; color: rgba(255, 255, 255, 0.92);
|
||||
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(var(--src), 0.34);
|
||||
transition: background 0.16s, border-color 0.16s, transform 0.16s, box-shadow 0.18s; }
|
||||
.vdl-src-search:hover { background: rgba(var(--src), 0.18); border-color: rgba(var(--src), 0.62);
|
||||
transform: translateY(-1px); box-shadow: 0 6px 16px -8px rgb(var(--src)); }
|
||||
.vdl-src-search:disabled { opacity: 0.5; cursor: default; transform: none; box-shadow: none; }
|
||||
.vdl-src-auto { --glow: var(--src); position: relative; overflow: hidden; flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 14px; border-radius: 10px; cursor: pointer; font-size: 12px; font-weight: 850; letter-spacing: 0.01em;
|
||||
color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); border: 1px solid rgba(var(--src), 0.55);
|
||||
background: linear-gradient(135deg, rgba(var(--src), 0.96), rgba(var(--src), 0.56));
|
||||
box-shadow: 0 6px 18px -7px rgb(var(--src)), inset 0 1px 0 rgba(255, 255, 255, 0.26);
|
||||
animation: vdlAutoGlow 3s ease-in-out infinite; transition: transform 0.16s, box-shadow 0.2s, filter 0.16s; }
|
||||
.vdl-src-auto:hover { transform: translateY(-1px); filter: brightness(1.06);
|
||||
box-shadow: 0 9px 22px -7px rgb(var(--src)), inset 0 1px 0 rgba(255, 255, 255, 0.32); }
|
||||
.vdl-src-auto::after { content: ''; position: absolute; inset: 0; transform: translateX(-130%);
|
||||
background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.45) 50%, transparent 65%); }
|
||||
.vdl-src-auto:hover::after { animation: vdlSheen 0.7s ease 1; }
|
||||
.vdl-src-auto:disabled { opacity: 0.5; cursor: default; transform: none; box-shadow: none; filter: none; animation: none; }
|
||||
.vdl-src-empty { font-size: 13px; color: rgba(255, 255, 255, 0.5); padding: 12px 14px; border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.03); border: 1px dashed rgba(255, 255, 255, 0.12); }
|
||||
/* (the Auto-pick highlight now lives with the flat result list — .vdl-res--auto
|
||||
.vdl-res-main below — so the chosen row is tinted + ringed in place.) */
|
||||
/* the two per-source actions = a SEGMENTED pair (shared seam): MANUAL ghost
|
||||
outline, AUTO flat solid brand. Sharp, mono, UPPERCASE; AUTO inverts on hover. */
|
||||
.vdl-src-actions { flex-shrink: 0; display: inline-flex; align-items: stretch; }
|
||||
.vdl-src-search, .vdl-src-auto { padding: 7px 14px; border-radius: 0; cursor: pointer;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; border: 1px solid;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s; }
|
||||
.vdl-src-search { background: transparent; color: rgba(255, 255, 255, 0.82);
|
||||
border-color: rgba(255, 255, 255, 0.24); border-right-width: 0; }
|
||||
.vdl-src-search:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
.vdl-src-search:disabled { opacity: 0.4; cursor: default; }
|
||||
.vdl-src-auto { background: rgb(var(--src)); color: #08111f; border-color: rgb(var(--src)); }
|
||||
.vdl-src-auto:hover { background: transparent; color: rgb(var(--src)); }
|
||||
.vdl-src-auto:disabled { opacity: 0.4; cursor: default; }
|
||||
.vdl-src-empty { font-size: 13px; color: rgba(255, 255, 255, 0.5); padding: 12px 14px; border-radius: 0;
|
||||
background: transparent; border: 1px dashed rgba(255, 255, 255, 0.16); }
|
||||
/* (the Auto-pick highlight now lives with the brutalist result cards — .vdl-res--auto
|
||||
below — so the chosen card is ringed with a hard offset shadow in place.) */
|
||||
@media (max-width: 560px) {
|
||||
.vdl-src { flex-wrap: wrap; }
|
||||
.vdl-src .vdl-src-actions { width: 100%; }
|
||||
|
|
@ -3301,6 +3273,11 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
transition: all 0.15s ease; }
|
||||
.vdl-season-search:hover { background: rgba(var(--accent-rgb), 0.3); border-color: rgba(var(--accent-rgb), 0.6);
|
||||
transform: translateY(-1px); }
|
||||
.vdl-season-grab { flex-shrink: 0; height: 28px; padding: 0 12px; border-radius: 8px; cursor: pointer; font-size: 12px; font-weight: 700;
|
||||
background: rgba(var(--accent-rgb), 0.16); border: 1px solid rgba(var(--accent-rgb), 0.4); color: #fff;
|
||||
white-space: nowrap; transition: all 0.15s ease; }
|
||||
.vdl-season-grab:hover { background: rgba(var(--accent-rgb), 0.32); border-color: rgba(var(--accent-rgb), 0.65); transform: translateY(-1px); }
|
||||
.vdl-season-grab:disabled { opacity: 0.7; cursor: progress; transform: none; }
|
||||
.vdl-season-chev { flex-shrink: 0; font-size: 13px; color: rgba(255, 255, 255, 0.5); transition: transform 0.25s ease; }
|
||||
.vdl-season--open .vdl-season-chev { transform: rotate(180deg); }
|
||||
.vdl-season-body { max-height: 0; overflow: hidden; transition: max-height 0.32s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
|
|
@ -3338,8 +3315,8 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vdl-ep-search { max-height: 0; overflow: hidden; transition: max-height 0.3s cubic-bezier(0.2, 0.7, 0.2, 1); }
|
||||
.vdl-ep--open .vdl-ep-search { max-height: 360px; }
|
||||
.vdl-ep-srcs { display: flex; flex-direction: column; gap: 7px; padding: 3px 14px 12px 42px; }
|
||||
.vdl-src--mini { padding: 8px 11px; border-radius: 10px; animation: none; }
|
||||
.vdl-src--mini .vdl-src-icon { width: 32px; height: 32px; border-radius: 9px; }
|
||||
.vdl-src--mini { padding: 8px 11px; border-radius: 0; animation: none; }
|
||||
.vdl-src--mini .vdl-src-icon { width: 30px; height: 30px; border-radius: 0; }
|
||||
.vdl-src--mini .vdl-src-emoji { font-size: 15px; }
|
||||
.vdl-src--mini .vdl-src-name { font-size: 13px; }
|
||||
|
||||
|
|
@ -3373,87 +3350,74 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vdl-res-demo { background: rgba(245, 158, 11, 0.14); color: #fbcd7a; }
|
||||
.vdl-res-err { color: #fbcd7a; }
|
||||
|
||||
/* ── result cards: cinematic, card-based ────────────────────────────────────
|
||||
A bold quality badge (resolution over source) anchors the left, the release
|
||||
NAME is the hero, the meta is a row of crisp pills, then size · verdict · Get.
|
||||
.vdl-res is a column so the live tracker docks under the chosen card on grab. */
|
||||
.vdl-res { position: relative; display: flex; flex-direction: column; border-radius: 14px; overflow: hidden;
|
||||
margin-bottom: 9px; animation: vdlRise 0.34s cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.022));
|
||||
border: 1px solid rgba(255, 255, 255, 0.07); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition: transform 0.18s cubic-bezier(0.2, 0.7, 0.2, 1), border-color 0.18s, box-shadow 0.22s; }
|
||||
.vdl-res-main { display: flex; align-items: center; gap: 14px; padding: 12px 14px; }
|
||||
.vdl-res:hover { transform: translateY(-2px); border-color: rgba(var(--accent-rgb), 0.4);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 18px 34px -20px rgba(0, 0, 0, 0.95); }
|
||||
.vdl-res--rejected { opacity: 0.48; }
|
||||
.vdl-res--rejected:hover { opacity: 0.85; }
|
||||
/* accepted releases get a faint accent edge; the grabbed/auto pick lights up fully */
|
||||
.vdl-res--ok::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
|
||||
background: linear-gradient(rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.4)); box-shadow: 0 0 14px 0 rgba(var(--accent-rgb), 0.4); }
|
||||
.vdl-res--grabbed { border-color: rgba(var(--accent-rgb), 0.55); }
|
||||
.vdl-res--auto { border-color: rgba(var(--accent-rgb), 0.85) !important;
|
||||
box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.5), 0 18px 36px -16px rgba(var(--accent-rgb), 0.85); }
|
||||
/* quality badge — bold + cinematic, resolution over the source word */
|
||||
.vdl-q { flex-shrink: 0; width: 58px; display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||
.vdl-q-res { width: 100%; text-align: center; font-size: 15px; font-weight: 900; letter-spacing: -0.02em; color: #06210f;
|
||||
padding: 7px 0; border-radius: 11px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45), 0 5px 14px -7px rgba(0, 0, 0, 0.7); }
|
||||
.vdl-q-res--4k { background: linear-gradient(140deg, #ffd76a, #f0a830); }
|
||||
.vdl-q-res--1080 { background: linear-gradient(140deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.7)); }
|
||||
.vdl-q-res--720 { background: linear-gradient(140deg, #8fb3ff, #5f86d6); color: #0a1020; }
|
||||
.vdl-q-res--sd { background: linear-gradient(140deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.22)); color: #11141a; }
|
||||
.vdl-q-src { font-size: 9.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: rgba(255, 255, 255, 0.42); }
|
||||
.vdl-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||
.vdl-info-title { font-size: 13.5px; font-weight: 700; color: #fff; letter-spacing: -0.01em;
|
||||
/* ── result cards: FLAT / BRUTALIST ──────────────────────────────────────────
|
||||
3 hard lines per card: [QUALITY] + release name · UPPERCASE dot-separated spec
|
||||
· verdict + a [ GET ] button. Monospace, sharp corners, hard borders, flat
|
||||
colour. .vdl-res stays a column so the live tracker docks under it on grab. */
|
||||
.vdl-res { position: relative; display: flex; flex-direction: column; border-radius: 0;
|
||||
margin-bottom: 7px; background: rgba(255, 255, 255, 0.018);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
transition: background 0.12s, border-color 0.12s, transform 0.12s; }
|
||||
.vdl-res-main { display: flex; flex-direction: column; gap: 7px; padding: 11px 13px; }
|
||||
.vdl-res:hover { transform: translateX(2px); border-color: rgba(var(--accent-rgb), 0.5);
|
||||
background: rgba(255, 255, 255, 0.04); }
|
||||
.vdl-res--rejected { opacity: 0.42; }
|
||||
.vdl-res--rejected:hover { opacity: 0.8; }
|
||||
/* accepted releases get a hard accent left-bar; grabbed/auto pick gets a hard offset shadow */
|
||||
.vdl-res--ok { border-left: 3px solid rgb(var(--accent-rgb)); }
|
||||
.vdl-res--grabbed { border-color: rgb(var(--accent-rgb)); }
|
||||
.vdl-res--auto { border-color: rgb(var(--accent-rgb)) !important;
|
||||
box-shadow: 4px 4px 0 0 rgba(var(--accent-rgb), 0.55); }
|
||||
/* line 1 — bracketed quality block + release name */
|
||||
.vdl-r-l1 { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.vdl-r-q { flex-shrink: 0; font-size: 11px; font-weight: 800; letter-spacing: 0.03em; padding: 3px 8px;
|
||||
border-radius: 0; color: #08111f; }
|
||||
.vdl-r-q--4k { background: #f0a830; }
|
||||
.vdl-r-q--1080 { background: rgb(var(--accent-rgb)); }
|
||||
.vdl-r-q--720 { background: #7fa8e8; color: #0a1020; }
|
||||
.vdl-r-q--sd { background: rgba(255, 255, 255, 0.45); color: #11141a; }
|
||||
.vdl-r-title { font-size: 12.5px; font-weight: 600; color: #fff;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vdl-info-tags { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.vdl-tag { font-size: 10px; font-weight: 800; letter-spacing: 0.02em; text-transform: uppercase; padding: 3px 7px; border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.07); color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.05); }
|
||||
.vdl-tag--hdr { background: linear-gradient(100deg, rgba(245, 158, 11, 0.38), rgba(168, 85, 247, 0.38)); color: #fff; border-color: transparent; }
|
||||
.vdl-tag--rep { background: rgba(108, 211, 145, 0.16); color: #8fe7af; border-color: transparent; }
|
||||
.vdl-tag--grp { background: transparent; color: rgba(255, 255, 255, 0.42); border-color: rgba(255, 255, 255, 0.12);
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace; text-transform: none; letter-spacing: 0; }
|
||||
.vdl-avail { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 700; color: #8fe7af; }
|
||||
.vdl-avail-ic { font-size: 8px; color: #6cd391; }
|
||||
.vdl-avail--seed { color: rgba(255, 255, 255, 0.55); }
|
||||
.vdl-res-right { flex-shrink: 0; display: flex; align-items: center; gap: 11px; }
|
||||
.vdl-size { font-size: 14px; font-weight: 800; color: #fff; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.vdl-size-u { font-size: 9.5px; font-weight: 700; color: rgba(255, 255, 255, 0.4); margin-left: 1px; }
|
||||
.vdl-flag { flex-shrink: 0; width: 22px; height: 22px; display: grid; place-items: center; border-radius: 7px;
|
||||
font-size: 12px; font-weight: 900; }
|
||||
.vdl-flag--ok { background: rgba(108, 211, 145, 0.18); color: #6cd391; }
|
||||
.vdl-flag--no { background: rgba(245, 158, 11, 0.16); color: #fbcd7a; }
|
||||
/* Get — the accent hero pill */
|
||||
.vdl-res-grab { flex-shrink: 0; display: inline-flex; align-items: center; gap: 5px; padding: 8px 15px; border-radius: 10px;
|
||||
cursor: pointer; font-size: 12px; font-weight: 850; letter-spacing: 0.01em; color: #06210f; border: none;
|
||||
background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.72));
|
||||
box-shadow: 0 5px 14px -6px rgb(var(--accent-rgb)), inset 0 1px 0 rgba(255, 255, 255, 0.32);
|
||||
transition: transform 0.15s ease, filter 0.15s ease, box-shadow 0.2s ease; }
|
||||
.vdl-res-grab:hover { transform: translateY(-1px); filter: brightness(1.07);
|
||||
box-shadow: 0 8px 18px -6px rgb(var(--accent-rgb)), inset 0 1px 0 rgba(255, 255, 255, 0.4); }
|
||||
.vdl-res-grab-ic { font-size: 14px; line-height: 1; }
|
||||
.vdl-res-grab--busy { opacity: 0.7; cursor: progress; }
|
||||
/* line 2 — UPPERCASE dot-separated spec line */
|
||||
.vdl-r-l2 { font-size: 10.5px; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
/* line 3 — verdict (left) + GET (right) */
|
||||
.vdl-r-l3 { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.vdl-r-verdict { font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vdl-r-verdict--ok { color: #6cd391; }
|
||||
.vdl-r-verdict--no { color: #e8a23a; }
|
||||
/* GET — the one filled action: flat solid accent, inverts on hover */
|
||||
.vdl-res-grab { flex-shrink: 0; padding: 6px 16px; border-radius: 0; cursor: pointer;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
|
||||
font-size: 11px; font-weight: 800; letter-spacing: 0.06em; color: #08111f;
|
||||
background: rgb(var(--accent-rgb)); border: 1px solid rgb(var(--accent-rgb));
|
||||
transition: background 0.12s, color 0.12s; }
|
||||
.vdl-res-grab:hover { background: transparent; color: rgb(var(--accent-rgb)); }
|
||||
.vdl-res-grab--busy { opacity: 0.6; cursor: progress; }
|
||||
.vdl-btn--busy { opacity: 0.7; cursor: progress; }
|
||||
@media (max-width: 520px) {
|
||||
.vdl-res-main { flex-wrap: wrap; }
|
||||
.vdl-info { flex-basis: 100%; order: 3; }
|
||||
.vdl-r-l1 { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
/* live download tracker docked under the grabbed card */
|
||||
.vdl-res-track { padding: 10px 13px 12px; border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.22); animation: vdlRise 0.3s both; }
|
||||
.vdl-res-track { padding: 10px 13px 12px; border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; }
|
||||
.vdl-res-track-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||
.vdl-res-track-state { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 800; color: #fff; }
|
||||
.vdl-res-track-pct { font-size: 12px; font-weight: 800; color: rgba(255, 255, 255, 0.6); font-variant-numeric: tabular-nums; }
|
||||
.vdl-res-track-go { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; border-radius: 9px;
|
||||
cursor: pointer; font-size: 11.5px; font-weight: 800; color: #fff; background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18); transition: background 0.15s, border-color 0.15s, transform 0.15s; }
|
||||
.vdl-res-track-go:hover { background: rgba(255, 255, 255, 0.14); border-color: rgba(255, 255, 255, 0.32); transform: translateY(-1px); }
|
||||
.vdl-res-track-bar { height: 6px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; }
|
||||
.vdl-res-track-fill { display: block; height: 100%; width: 0; border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(var(--accent-rgb), 0.82), rgb(var(--accent-rgb)));
|
||||
box-shadow: 0 0 10px 0 rgba(var(--accent-rgb), 0.6); transition: width 0.5s ease; }
|
||||
.vdl-res-track--done .vdl-res-track-fill { background: linear-gradient(90deg, #58d39a, #6cd391); box-shadow: 0 0 10px 0 rgba(108, 211, 145, 0.6); }
|
||||
.vdl-res-track--fail .vdl-res-track-fill { background: linear-gradient(90deg, #f0a830, #fbcd7a); box-shadow: none; }
|
||||
.vdl-res-track-state { display: inline-flex; align-items: center; gap: 7px; font-size: 11px; font-weight: 700;
|
||||
letter-spacing: 0.06em; text-transform: uppercase; color: #fff; }
|
||||
.vdl-res-track-pct { font-size: 11px; font-weight: 700; color: rgba(255, 255, 255, 0.6); font-variant-numeric: tabular-nums; }
|
||||
.vdl-res-track-go { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; padding: 5px 12px; border-radius: 0;
|
||||
cursor: pointer; font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #fff;
|
||||
background: transparent; border: 1px solid rgba(255, 255, 255, 0.24); transition: background 0.12s, color 0.12s, border-color 0.12s; }
|
||||
.vdl-res-track-go:hover { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.4); }
|
||||
.vdl-res-track-bar { height: 5px; border-radius: 0; background: rgba(255, 255, 255, 0.1); overflow: hidden; }
|
||||
.vdl-res-track-fill { display: block; height: 100%; width: 0; border-radius: 0;
|
||||
background: rgb(var(--accent-rgb)); transition: width 0.5s ease; }
|
||||
.vdl-res-track--done .vdl-res-track-fill { background: #6cd391; }
|
||||
.vdl-res-track--fail .vdl-res-track-fill { background: #f0a830; }
|
||||
.vdl-res-track--done .vdl-res-track-state { color: #8fe7af; }
|
||||
.vdl-res-track--fail .vdl-res-track-state { color: #fbcd7a; }
|
||||
.vdl-res-track-spin { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0;
|
||||
|
|
@ -3541,3 +3505,79 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
}
|
||||
.em-retry-global:hover { transform: none; background: rgba(245, 158, 11, 0.24); }
|
||||
.em-retry-global:disabled { opacity: 0.55; cursor: default; }
|
||||
|
||||
/* ── Import page (manual / failed-import resolution) — video/video-import.js ──── */
|
||||
.vimp-page { max-width: 1080px; margin: 0 auto; padding: 8px 4px 40px; }
|
||||
.vimp-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
.vimp-title { font-size: 26px; font-weight: 800; color: #fff; letter-spacing: -0.02em; margin: 0; display: flex; align-items: center; gap: 10px; }
|
||||
.vimp-count { font-size: 13px; font-weight: 800; color: #06210f; background: rgb(var(--accent-rgb)); border-radius: 999px; padding: 2px 10px; min-width: 22px; text-align: center; }
|
||||
.vimp-count:empty { display: none; }
|
||||
.vimp-sub { font-size: 13.5px; color: rgba(255, 255, 255, 0.5); margin: 4px 0 0; }
|
||||
.vimp-refresh { flex-shrink: 0; padding: 8px 16px; border-radius: 10px; cursor: pointer; font-size: 13px; font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
transition: background 0.15s, border-color 0.15s; }
|
||||
.vimp-refresh:hover { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.28); }
|
||||
|
||||
.vimp-grid { display: flex; flex-direction: column; gap: 10px; }
|
||||
.vimp-card { display: flex; align-items: center; gap: 16px; padding: 14px 16px; border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.022));
|
||||
border: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.vimp-card-main { flex: 1; min-width: 0; }
|
||||
.vimp-card-title { font-size: 14.5px; font-weight: 700; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vimp-card-file { font-size: 12px; color: rgba(255, 255, 255, 0.5); font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
|
||||
.vimp-card-reason { font-size: 12px; font-weight: 600; color: #e8a23a; margin-top: 5px; }
|
||||
.vimp-card-side { flex-shrink: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 9px; }
|
||||
.vimp-card-kind { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: rgba(255, 255, 255, 0.45); }
|
||||
.vimp-card-actions { display: flex; gap: 8px; }
|
||||
.vimp-btn { padding: 7px 14px; border-radius: 9px; cursor: pointer; font-size: 12.5px; font-weight: 700; border: 1px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s, filter 0.15s; }
|
||||
.vimp-btn--place { color: #06210f; background: rgb(var(--accent-rgb)); }
|
||||
.vimp-btn--place:hover { filter: brightness(1.08); }
|
||||
.vimp-btn--place:disabled { opacity: 0.5; cursor: default; filter: none; }
|
||||
.vimp-btn--dismiss, .vimp-btn--ghost { color: rgba(255, 255, 255, 0.85); background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.14); }
|
||||
.vimp-btn--dismiss:hover, .vimp-btn--ghost:hover { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.28); }
|
||||
|
||||
.vimp-state { text-align: center; padding: 64px 20px; }
|
||||
.vimp-state-ic { font-size: 38px; color: #6cd391; }
|
||||
.vimp-state-title { font-size: 17px; font-weight: 700; color: #fff; margin-top: 10px; }
|
||||
.vimp-state-sub { font-size: 13px; color: rgba(255, 255, 255, 0.45); margin-top: 6px; }
|
||||
|
||||
/* resolve modal */
|
||||
.vimp-modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 20px; }
|
||||
.vimp-modal-scrim { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(3px); }
|
||||
.vimp-modal-card { position: relative; width: 100%; max-width: 540px; max-height: 86vh; display: flex; flex-direction: column;
|
||||
background: #15171d; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px; box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.8); }
|
||||
.vimp-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 20px 12px; }
|
||||
.vimp-modal-title { font-size: 18px; font-weight: 800; color: #fff; margin: 0; }
|
||||
.vimp-modal-file { font-size: 12px; color: rgba(255, 255, 255, 0.5); margin-top: 4px; word-break: break-word; }
|
||||
.vimp-modal-x { flex-shrink: 0; width: 30px; height: 30px; border-radius: 8px; cursor: pointer; font-size: 20px; line-height: 1;
|
||||
color: rgba(255, 255, 255, 0.6); background: transparent; border: none; }
|
||||
.vimp-modal-x:hover { background: rgba(255, 255, 255, 0.08); color: #fff; }
|
||||
.vimp-kindtabs { display: flex; gap: 8px; padding: 0 20px 12px; }
|
||||
.vimp-kindtab { padding: 7px 16px; border-radius: 9px; cursor: pointer; font-size: 13px; font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); }
|
||||
.vimp-kindtab--on { color: #06210f; background: rgb(var(--accent-rgb)); border-color: transparent; }
|
||||
.vimp-search { padding: 0 20px 10px; }
|
||||
.vimp-search-input { width: 100%; padding: 10px 13px; border-radius: 10px; font-size: 14px; color: #fff;
|
||||
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.14); }
|
||||
.vimp-search-input:focus { outline: none; border-color: rgba(var(--accent-rgb), 0.6); }
|
||||
.vimp-results { overflow-y: auto; padding: 0 12px; display: flex; flex-direction: column; gap: 4px; min-height: 80px; }
|
||||
.vimp-res-note { font-size: 13px; color: rgba(255, 255, 255, 0.45); padding: 18px 8px; text-align: center; }
|
||||
.vimp-res { display: flex; align-items: center; gap: 12px; width: 100%; padding: 8px; border-radius: 10px; cursor: pointer; text-align: left;
|
||||
background: transparent; border: 1px solid transparent; }
|
||||
.vimp-res:hover { background: rgba(255, 255, 255, 0.05); }
|
||||
.vimp-res--on { background: rgba(var(--accent-rgb), 0.14); border-color: rgba(var(--accent-rgb), 0.5); }
|
||||
.vimp-res-img, .vimp-res-ph { width: 38px; height: 56px; border-radius: 6px; flex-shrink: 0; object-fit: cover; }
|
||||
.vimp-res-ph { display: grid; place-items: center; font-size: 18px; background: rgba(255, 255, 255, 0.06); }
|
||||
.vimp-res-info { min-width: 0; display: flex; flex-direction: column; }
|
||||
.vimp-res-title { font-size: 13.5px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vimp-res-meta { font-size: 11.5px; color: rgba(255, 255, 255, 0.5); margin-top: 2px; }
|
||||
.vimp-res--owned .vimp-res-meta { color: #6cd391; }
|
||||
.vimp-ep { display: flex; gap: 10px; flex-wrap: wrap; padding: 12px 20px 4px; }
|
||||
.vimp-ep-field { font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.6); display: flex; flex-direction: column; gap: 4px; }
|
||||
.vimp-ep-field--wide { flex: 1; min-width: 140px; }
|
||||
.vimp-ep-field input { padding: 8px 11px; border-radius: 9px; font-size: 13px; color: #fff;
|
||||
background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.14); width: 90px; }
|
||||
.vimp-ep-field--wide input { width: 100%; }
|
||||
.vimp-modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 20px 18px; border-top: 1px solid rgba(255, 255, 255, 0.06); margin-top: 8px; }
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@
|
|||
{ id: 'video-calendar', label: 'Calendar' },
|
||||
{ id: 'video-automations', label: 'Automations' },
|
||||
{ id: 'video-tools', label: 'Tools' },
|
||||
{ id: 'video-import', label: 'Import', shared: true },
|
||||
{ id: 'video-import', label: 'Import' },
|
||||
{ id: 'video-settings', label: 'Settings' },
|
||||
{ id: 'video-issues', label: 'Issues', shared: true },
|
||||
{ id: 'video-help', label: 'Help & Docs', shared: true },
|
||||
|
|
|
|||
Loading…
Reference in a new issue