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.
117 lines
6.3 KiB
Python
117 lines
6.3 KiB
Python
"""Video library-organisation settings — the $token template engine + the settings
|
|
model. Same standard as the music side's file-organisation templates, for video's
|
|
movie/episode shape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
from core.video import organization
|
|
from core.video.library_paths import plan_path
|
|
|
|
|
|
# ── settings model ────────────────────────────────────────────────────────────
|
|
def test_normalize_fills_and_validates():
|
|
d = organization.normalize(None)
|
|
assert d == organization.default_settings()
|
|
d = organization.normalize({"transfer_mode": "MOVE", "verify_with_ffprobe": 0,
|
|
"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 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
|
|
assert d["episode_template"] == "$series/$episode"
|
|
# an invalid transfer mode falls back to the default
|
|
assert organization.normalize({"transfer_mode": "torrent"})["transfer_mode"] == "copy"
|
|
# youtube template is a first-class setting: defaulted + editable + blank→default
|
|
assert organization.default_settings()["youtube_template"] == organization.DEFAULTS["youtube_template"]
|
|
assert organization.normalize({"youtube_template": "$channel/$title"})["youtube_template"] == "$channel/$title"
|
|
assert organization.normalize({"youtube_template": " "})["youtube_template"] == organization.DEFAULTS["youtube_template"]
|
|
|
|
|
|
def test_load_save_roundtrip():
|
|
class FakeDB:
|
|
def __init__(self):
|
|
self.store = {}
|
|
|
|
def get_setting(self, key, default=None):
|
|
return self.store.get(key, default)
|
|
|
|
def set_setting(self, key, value):
|
|
self.store[key] = value
|
|
|
|
db = FakeDB()
|
|
assert organization.load(db) == organization.default_settings() # nothing stored yet
|
|
saved = organization.save(db, {"transfer_mode": "move"})
|
|
assert saved["transfer_mode"] == "move"
|
|
assert organization.load(db)["transfer_mode"] == "move" # persisted + reloads
|
|
|
|
|
|
# ── template rendering ────────────────────────────────────────────────────────
|
|
def test_default_movie_template_matches_the_standard():
|
|
fields = {"title": "The Matrix", "year": 1999, "quality": "Bluray-1080p"}
|
|
got = organization.render_path("movie", "/m", fields, organization.default_settings(), ".mkv")
|
|
std = plan_path("movie", "/m", {"title": "The Matrix", "year": 1999}, "Bluray-1080p", ".mkv")
|
|
assert got["path"] == std["path"] # default template == the hardcoded Radarr standard
|
|
|
|
|
|
def test_default_episode_template_matches_the_standard():
|
|
fields = {"title": "Breaking Bad", "season": 1, "episode": 1,
|
|
"episode_title": "Pilot", "quality": "WEBDL-1080p"}
|
|
got = organization.render_path("episode", "/t", fields, organization.default_settings(), ".mkv")
|
|
assert got["path"] == os.path.join("/t", "Breaking Bad", "Season 01",
|
|
"Breaking Bad - S01E01 - Pilot WEBDL-1080p.mkv")
|
|
|
|
|
|
def test_empty_tokens_dont_leave_dangling_separators():
|
|
# no episode title, no quality → no stray ' - ' or double spaces
|
|
fields = {"title": "Show", "season": 2, "episode": 5, "episode_title": "", "quality": ""}
|
|
got = organization.render_path("episode", "/t", fields, organization.default_settings(), ".mkv")
|
|
assert got["filename"] == "Show - S02E05.mkv"
|
|
|
|
|
|
def test_missing_year_doesnt_leave_empty_parens():
|
|
fields = {"title": "Unknownish", "year": None, "quality": "Bluray-1080p"}
|
|
got = organization.render_path("movie", "/m", fields, organization.default_settings(), ".mkv")
|
|
assert "()" not in got["path"]
|
|
assert got["dir"] == os.path.join("/m", "Unknownish")
|
|
|
|
|
|
def test_token_values_cannot_inject_extra_folders():
|
|
# a slash inside a title is sanitised, not treated as a path separator
|
|
fields = {"title": "AC/DC Live", "year": 2020, "quality": "Bluray-1080p"}
|
|
got = organization.render_path("movie", "/m", fields, organization.default_settings(), ".mkv")
|
|
assert got["dir"] == os.path.join("/m", "ACDC Live (2020)")
|
|
|
|
|
|
# ── youtube channels: Plex "TV by date" organisation ─────────────────────────
|
|
def test_youtube_default_template_is_channel_year_date():
|
|
fields = {"channel": "Veritasium", "title": "How Electricity Works",
|
|
"published_at": "2024-03-15", "youtube_id": "abc123"}
|
|
got = organization.render_path("youtube", "/yt", fields, organization.default_settings(), ".mp4")
|
|
assert got["path"] == os.path.join(
|
|
"/yt", "Veritasium", "Season 2024",
|
|
"Veritasium - 2024-03-15 - How Electricity Works.mp4")
|
|
|
|
|
|
def test_youtube_sanitises_channel_and_title():
|
|
# slashes/illegal chars in channel or title can't spawn folders
|
|
fields = {"channel": "Mark/Rober", "title": "Glitter Bomb 4/5",
|
|
"published_at": "2023-12-01", "youtube_id": "v1"}
|
|
got = organization.render_path("youtube", "/yt", fields, organization.default_settings(), ".mp4")
|
|
assert got["dir"] == os.path.join("/yt", "MarkRober", "Season 2023")
|
|
assert got["filename"] == "MarkRober - 2023-12-01 - Glitter Bomb 45.mp4"
|
|
|
|
|
|
def test_youtube_undated_falls_back_cleanly():
|
|
# no date → no empty 'Season ' garbage, no dangling ' - ' in the filename
|
|
fields = {"channel": "Some Channel", "title": "Mystery", "published_at": None}
|
|
got = organization.render_path("youtube", "/yt", fields, organization.default_settings(), ".mp4")
|
|
assert got["dir"] == os.path.join("/yt", "Some Channel", "Season")
|
|
assert got["filename"] == "Some Channel - Mystery.mp4"
|