video org: youtube channel template (Plex TV-by-date)

add a 'youtube' scope to render_path: channel=show, season=upload year,
episode named '$channel - $date - $title'. rides the existing $token
engine (sanitised, dangling-separator tidy). undated videos fall back
cleanly (no empty 'Season ' / no stray ' - '). default template editable
like the movie/episode ones; per-channel override comes with the settings
modal later.
This commit is contained in:
BoulderBadgeDad 2026-06-25 19:20:18 -07:00
parent 568e297d36
commit 00d05a04f5
2 changed files with 54 additions and 1 deletions

View file

@ -37,6 +37,9 @@ 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",
@ -60,7 +63,7 @@ def normalize(raw: Any) -> dict:
d = default_settings()
if not isinstance(raw, dict):
return d
for key in ("movie_template", "episode_template"):
for key in ("movie_template", "episode_template", "youtube_template"):
v = raw.get(key)
if isinstance(v, str) and v.strip():
d[key] = v.strip()
@ -154,6 +157,26 @@ def _episode_values(f: dict) -> dict:
}
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
@ -194,6 +217,9 @@ def render_path(scope: Any, root: Any, fields: dict, settings: Any, ext: Any) ->
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)}

View file

@ -83,3 +83,30 @@ def test_token_values_cannot_inject_extra_folders():
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"