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:
BoulderBadgeDad 2026-06-21 01:44:35 -07:00
parent 366b5a266d
commit efa64db04d
29 changed files with 3452 additions and 234 deletions

View file

@ -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

View file

@ -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
View 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})

View file

@ -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)

View file

@ -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
ownedlibrary 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
View 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
View 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
View 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
View 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
View 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
View 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"]

View file

@ -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:

View file

@ -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

View file

@ -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 ------------------------

View 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

View 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

View 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"

View 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"] == []

View 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

View 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)")

View file

@ -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).

View 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 &amp; B &lt;x&gt;</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"]

View 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

View file

@ -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 &mdash; 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&hellip;</div>
</div>
<div class="vimp-grid" data-vimp-grid></div>
<div class="vimp-state hidden" data-vimp-empty>
<div class="vimp-state-ic">&#10003;</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">&hellip;</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">&hellip;</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 &mdash; poster.jpg, fanart.jpg, clearlogo.png (plus season posters for shows) &mdash; 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 &mdash; 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 &mdash; 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 &lt;video&gt;.&lt;lang&gt;.srt.</small>
</div>
<div class="form-group">
<button class="test-button" id="vo-reset" style="background: #666;">&#128260; 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>

View file

@ -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">&#10003; MEETS PROFILE</span>'
: '<span class="vdl-r-verdict vdl-r-verdict--no" title="' + esc(r.rejected || '') + '">&#10007; ' + 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');

View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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&hellip;</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">&times;</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 &amp; TMDB&hellip;" 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&hellip;</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) || 'Couldnt place the file', 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Place file'; } }
})
.catch(function () { toast('Couldnt 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('Couldnt dismiss', 'error'); })
.catch(function () { toast('Couldnt 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();
})();

View file

@ -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() {

View file

@ -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; }

View file

@ -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 },