soulsync/core/video/organization.py
BoulderBadgeDad f6360e90da youtube downloads: write episode sidecars (-thumb.jpg + .nfo), gated by toggles
best-in-class for Plex/Jellyfin, matching ytdl-sub: each youtube episode now gets a
'<name>-thumb.jpg' (episode art) + a '<name>.nfo' (<episodedetails>: title, plot from
the video description, aired date, season=year, episode=MMDD, channel, youtube uniqueid).

- yt-dlp now writes the thumbnail + an info.json next to the staged video; the import
  step renames the thumb, mines the json for the nfo, and cleans both up (so nothing
  litters the download folder when a toggle is off).
- gated by the SAME post-processing toggles as movie/TV (save_artwork / write_nfo),
  which already exist in Settings → Library; youtube just honours them now.
- flipped save_artwork + write_nfo defaults ON (cheap, local). download_subtitles stays
  opt-in (it hits OpenSubtitles — external + rate-limited).
build_episode_nfo + the gated sidecar I/O tested.
2026-06-26 13:57:52 -07:00

239 lines
8.9 KiB
Python

"""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",
# YouTube channels organise as a Plex "TV by date" show: channel = series,
# season = upload YEAR, episode named by upload DATE + title.
"youtube_template": "$channel/Season $year/$channel - $date - $title",
"verify_with_ffprobe": True,
"replace_existing": True,
"transfer_mode": "copy",
"carry_subtitles": True,
"save_artwork": True, # nfo + artwork sidecars on by default (cheap, local) — best-in-class
"write_nfo": True,
"download_subtitles": False, # opt-in: fetches from OpenSubtitles (external, rate-limited)
"subtitle_langs": "en",
}
_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", "youtube_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 _youtube_values(f: dict) -> dict:
"""Template values for a YouTube upload — channel-as-show, season=year, date-named
episode (Plex 'TV by date'). ``published_at``/``date`` is 'YYYY-MM-DD'."""
channel = f.get("channel") or f.get("series") or f.get("title") or "Unknown"
pub = str(f.get("published_at") or f.get("date") or "")[:10]
y = m = d = ""
if len(pub) == 10 and pub[4] == "-" and pub[7] == "-":
y, m, d = pub[0:4], pub[5:7], pub[8:10]
has_year = _plausible_year(y)
return {
"channel": channel,
"title": _str(f.get("title")) or "Unknown",
"year": y if has_year else "",
"date": pub if has_year else "", # only a trustworthy full date
"month": m if has_year else "",
"day": d if has_year else "",
"videoid": _str(f.get("youtube_id")),
}
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)
elif sc == "youtube":
tmpl = settings.get("youtube_template") or DEFAULTS["youtube_template"]
values = _youtube_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",
]