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:
parent
02a319c350
commit
f6360e90da
4 changed files with 149 additions and 6 deletions
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & <there>" 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue