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.
This commit is contained in:
BoulderBadgeDad 2026-06-26 13:57:52 -07:00
parent 02a319c350
commit f6360e90da
4 changed files with 149 additions and 6 deletions

View file

@ -44,9 +44,9 @@ DEFAULTS = {
"replace_existing": True,
"transfer_mode": "copy",
"carry_subtitles": True,
"save_artwork": False,
"write_nfo": False,
"download_subtitles": False,
"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",
}

View file

@ -98,6 +98,12 @@ def ydl_download_opts(profile: Any, dest_dir: str, dest_stem: str,
"merge_output_format": sel["merge_output_format"],
"paths": {"home": str(dest_dir or "")},
"outtmpl": dest_stem + ".%(ext)s",
# sidecars: the episode thumbnail (→ '<name>-thumb.jpg' on import) + the metadata
# json we mine for the .nfo (description / duration). ffmpeg (already needed to merge)
# normalises the thumbnail to jpg.
"writethumbnail": True,
"writeinfojson": True,
"postprocessors": [{"key": "FFmpegThumbnailsConvertor", "format": "jpg"}],
}
if cookie_opts:
opts.update(cookie_opts)
@ -144,6 +150,84 @@ def _default_move(src: str, dest: str) -> None:
shutil.move(src, dest)
def build_episode_nfo(fields: Dict[str, Any], *, description: Any = None, runtime: Any = None) -> str:
"""A Jellyfin/Kodi/Plex ``<episodedetails>`` sidecar for a YouTube 'episode'. Pure — the
description/runtime come from yt-dlp's info json. season = upload year, episode = MMDD
(Plex 'by date' matches on ``<aired>``; the numbers help Jellyfin/Kodi)."""
from xml.sax.saxutils import escape
date = str(fields.get("published_at") or "")[:10]
year = date[:4]
out = ['<?xml version="1.0" encoding="UTF-8"?>', '<episodedetails>',
' <title>%s</title>' % escape(str(fields.get("title") or fields.get("channel") or "Video"))]
if year.isdigit():
out.append(' <season>%s</season>' % year)
if len(date) == 10 and date[5:7].isdigit() and date[8:10].isdigit():
out.append(' <episode>%d</episode>' % int(date[5:7] + date[8:10]))
if description:
out.append(' <plot>%s</plot>' % escape(str(description)))
if date:
out.append(' <aired>%s</aired>' % escape(date))
if fields.get("channel"):
out.append(' <studio>%s</studio>' % escape(str(fields["channel"])))
if fields.get("youtube_id"):
out.append(' <uniqueid type="youtube" default="true">%s</uniqueid>' % escape(str(fields["youtube_id"])))
try:
if runtime:
out.append(' <runtime>%d</runtime>' % round(float(runtime) / 60))
except (TypeError, ValueError):
pass
out.append('</episodedetails>')
return "\n".join(out) + "\n"
def _silent_remove(path: str) -> None:
try:
os.remove(path)
except OSError:
pass
def _default_sidecars(staged_video: str, final_video: str, fields: Dict[str, Any],
settings: Dict[str, Any]) -> None:
"""Place the YouTube episode's sidecars next to the imported video — gated by the SAME
post-processing toggles as the movie/TV side: ``save_artwork`` ``<name>-thumb.jpg``
(server episode art), ``write_nfo`` ``<name>.nfo`` (metadata). yt-dlp dropped a
thumbnail + ``.info.json`` next to the staged video; we always clean those up (move the
wanted ones into the library, delete the rest), so nothing litters the download folder
when a toggle is off. Best-effort never fails the grab."""
settings = settings if isinstance(settings, dict) else {}
want_thumb, want_nfo = bool(settings.get("save_artwork")), bool(settings.get("write_nfo"))
try:
src_dir, src_stem = os.path.dirname(staged_video), os.path.splitext(os.path.basename(staged_video))[0]
dst_dir, dst_stem = os.path.dirname(final_video), os.path.splitext(os.path.basename(final_video))[0]
# thumbnail: keep as -thumb when wanted, else discard the staged copy
for ext in (".jpg", ".jpeg", ".png", ".webp"):
src_thumb = os.path.join(src_dir, src_stem + ext)
if os.path.exists(src_thumb):
if want_thumb:
os.makedirs(dst_dir or ".", exist_ok=True)
shutil.move(src_thumb, os.path.join(dst_dir, dst_stem + "-thumb" + (".jpg" if ext == ".jpeg" else ext)))
else:
_silent_remove(src_thumb)
break
# info json → mine for the nfo (when wanted), then always drop it
info = {}
info_path = os.path.join(src_dir, src_stem + ".info.json")
if os.path.exists(info_path):
try:
with open(info_path, encoding="utf-8") as f:
info = json.load(f)
except (ValueError, OSError):
info = {}
_silent_remove(info_path)
if want_nfo:
os.makedirs(dst_dir or ".", exist_ok=True)
with open(os.path.join(dst_dir, dst_stem + ".nfo"), "w", encoding="utf-8") as f:
f.write(build_episode_nfo(fields, description=info.get("description"), runtime=info.get("duration")))
except Exception: # noqa: BLE001 - sidecars are a nice-to-have, never fatal to the grab
logger.exception("youtube sidecars failed for %s", final_video)
def process_youtube_download(
dl: Dict[str, Any],
*,
@ -155,6 +239,7 @@ def process_youtube_download(
clear_wishlist: Callable[[Any], Any],
stage_dir: Optional[str] = None,
move: Callable[[str, str], Any] = _default_move,
sidecars: Callable[[str, str, Dict[str, Any], Dict[str, Any]], Any] = _default_sidecars,
progress_hook: Optional[Callable] = None,
cookie_opts: Optional[dict] = None,
now: Optional[Callable[[], str]] = None,
@ -211,6 +296,10 @@ def process_youtube_download(
else:
dest_path = staged_path or final_path
# episode sidecars (-thumb.jpg + .nfo) next to the imported video — gated by the
# save_artwork / write_nfo post-processing toggles (shared with the movie/TV side).
sidecars(staged_path, dest_path, youtube_fields_from_download(dl), settings)
completed = now()
update_row(dl.get("id"), status="completed", progress=100,
dest_path=dest_path, completed_at=completed)

View file

@ -19,9 +19,10 @@ def test_normalize_fills_and_validates():
"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()["save_artwork"] is True # on by default (cheap, local)
assert organization.default_settings()["write_nfo"] is True
assert organization.normalize({"save_artwork": 0})["save_artwork"] is False
assert organization.default_settings()["download_subtitles"] is False # opt-in (external API)
assert organization.default_settings()["subtitle_langs"] == "en"
assert organization.normalize({"subtitle_langs": "EN, es"})["subtitle_langs"] == "en,es"
assert d["movie_template"] == organization.DEFAULTS["movie_template"] # blank → default

View file

@ -174,6 +174,59 @@ def test_process_import_failure_is_terminal_and_keeps_the_wish():
assert calls["unwish"] == [] # not unwished → can retry
def test_build_episode_nfo():
from core.video.youtube_download import build_episode_nfo
nfo = build_episode_nfo({"title": "Cool", "channel": "Chan", "published_at": "2026-06-22",
"youtube_id": "abc"}, description="Hi & <there>", runtime=754)
assert "<title>Cool</title>" in nfo and "<season>2026</season>" in nfo
assert "<episode>622</episode>" in nfo and "<aired>2026-06-22</aired>" in nfo
assert "<studio>Chan</studio>" in nfo and '<uniqueid type="youtube"' in nfo
assert "Hi &amp; &lt;there&gt;" in nfo # xml-escaped
assert "<runtime>13</runtime>" in nfo # 754s ≈ 13 min
def test_default_sidecars_writes_thumb_and_nfo_when_on(tmp_path):
stage, lib = tmp_path / "stage", tmp_path / "lib"
stage.mkdir(); lib.mkdir()
base = "Chan - 2026-06-22 - Vid"
(stage / (base + ".mp4")).write_text("v")
(stage / (base + ".jpg")).write_text("img")
(stage / (base + ".info.json")).write_text('{"description": "Desc", "duration": 754}')
ytd._default_sidecars(str(stage / (base + ".mp4")), str(lib / (base + ".mp4")),
{"title": "Vid", "channel": "Chan", "published_at": "2026-06-22", "youtube_id": "v9"},
{"save_artwork": True, "write_nfo": True})
assert (lib / (base + "-thumb.jpg")).exists() # episode art
nfo = (lib / (base + ".nfo")).read_text()
assert "<title>Vid</title>" in nfo and "<aired>2026-06-22</aired>" in nfo and "Desc" in nfo
assert not (stage / (base + ".info.json")).exists() # mined + dropped
assert not (stage / (base + ".jpg")).exists() # moved out of staging
def test_default_sidecars_off_discards_staged_extras(tmp_path):
stage, lib = tmp_path / "s", tmp_path / "l"
stage.mkdir(); lib.mkdir()
(stage / "V.jpg").write_text("i")
(stage / "V.info.json").write_text("{}")
ytd._default_sidecars(str(stage / "V.mp4"), str(lib / "V.mp4"), {"title": "V"}, {})
assert not (stage / "V.jpg").exists() and not (stage / "V.info.json").exists() # cleaned up
assert not (lib / "V-thumb.jpg").exists() and not (lib / "V.nfo").exists() # nothing written
def test_process_passes_settings_to_sidecars():
calls, update_row, archive, clear = _recorder()
got = {}
def sc(staged, final, fields, settings):
got["settings"], got["fields"] = settings, fields
ytd.process_youtube_download(
_dl(), profile=default_profile(), settings={"write_nfo": True, "save_artwork": False},
download=lambda *a, **k: {"ok": True, "dest_path": "/yt/Chan/Season 2024/x.mp4"},
update_row=update_row, archive=archive, clear_wishlist=clear, sidecars=sc, now=lambda: "t")
assert got["settings"] == {"write_nfo": True, "save_artwork": False}
assert got["fields"]["youtube_id"] == "vid1"
def test_requeue_orphaned_youtube_recovers_only_dead_downloads():
"""After a restart no worker threads survive, so any 'downloading' YouTube row is an
orphan back to 'queued'. A row whose worker is still alive (in _active_worker_ids) and