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.
165 lines
5.7 KiB
Python
165 lines
5.7 KiB
Python
"""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",
|
|
]
|