From f6360e90da3fd5acd684c7c520d0875372028133 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 26 Jun 2026 13:57:52 -0700 Subject: [PATCH] youtube downloads: write episode sidecars (-thumb.jpg + .nfo), gated by toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit best-in-class for Plex/Jellyfin, matching ytdl-sub: each youtube episode now gets a '-thumb.jpg' (episode art) + a '.nfo' (: 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. --- core/video/organization.py | 6 +-- core/video/youtube_download.py | 89 ++++++++++++++++++++++++++++++++ tests/test_video_organization.py | 7 +-- tests/test_youtube_download.py | 53 +++++++++++++++++++ 4 files changed, 149 insertions(+), 6 deletions(-) diff --git a/core/video/organization.py b/core/video/organization.py index 79d07498..ae15420a 100644 --- a/core/video/organization.py +++ b/core/video/organization.py @@ -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", } diff --git a/core/video/youtube_download.py b/core/video/youtube_download.py index 03d2ec8d..d68efe9e 100644 --- a/core/video/youtube_download.py +++ b/core/video/youtube_download.py @@ -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 (→ '-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 ```` 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 ````; the numbers help Jellyfin/Kodi).""" + from xml.sax.saxutils import escape + date = str(fields.get("published_at") or "")[:10] + year = date[:4] + out = ['', '', + ' %s' % escape(str(fields.get("title") or fields.get("channel") or "Video"))] + if year.isdigit(): + out.append(' %s' % year) + if len(date) == 10 and date[5:7].isdigit() and date[8:10].isdigit(): + out.append(' %d' % int(date[5:7] + date[8:10])) + if description: + out.append(' %s' % escape(str(description))) + if date: + out.append(' %s' % escape(date)) + if fields.get("channel"): + out.append(' %s' % escape(str(fields["channel"]))) + if fields.get("youtube_id"): + out.append(' %s' % escape(str(fields["youtube_id"]))) + try: + if runtime: + out.append(' %d' % round(float(runtime) / 60)) + except (TypeError, ValueError): + pass + out.append('') + 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`` → ``-thumb.jpg`` + (server episode art), ``write_nfo`` → ``.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) diff --git a/tests/test_video_organization.py b/tests/test_video_organization.py index 04e2ff4a..6dba9eb4 100644 --- a/tests/test_video_organization.py +++ b/tests/test_video_organization.py @@ -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 diff --git a/tests/test_youtube_download.py b/tests/test_youtube_download.py index c64754f8..d4d4399d 100644 --- a/tests/test_youtube_download.py +++ b/tests/test_youtube_download.py @@ -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 & ", runtime=754) + assert "Cool" in nfo and "2026" in nfo + assert "622" in nfo and "2026-06-22" in nfo + assert "Chan" in nfo and '13" 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 "Vid" in nfo and "2026-06-22" 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