OverviewFilesCommitsIssuesDocsReleasesPeers

stremio-vsembed commits / 6637eb4

Library ingest: Jellyfin-layout naming, exFAT-safe (0.9.2)
Fetched copies are library content, not cache. This adds the naming layer they land in: Shows/<Title> (<Year>) [tmdbid-<id>]/Season NN/... and Movies/<Title> (<Year>) [tmdbid-<id>]/... The TMDB id is stated rather than inferred. The 2026-07-28 library cleanup found Jellyfin's own identification is ~92% right and unsafe to rename blindly, but that was retrofitting unknown files -- here the title was resolved FROM its id, so no runtime cross-check is needed. The volume is exFAT (verified: findmnt -> exfat /dev/sdb1), so " * / : < > ? \ | are forbidden. They are substituted rather than dropped, keeping titles readable; the colon maps to U+FF1A to match `Ghost in the Shell:STAND ALONE COMPLEX` already on the volume. _part_path returns a SIBLING of the destination: os.replace cannot cross a filesystem (EXDEV), and the library is a separate volume from the repo, so writing to <repo>/mp4-cache and renaming onto /mnt/usb would fail every time. Naming only -- nothing writes here yet. 15 new tests, 75 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6637eb4cf841f57c84411173de922a1f7a7fb2cd
by plankatron <fleeky@gmail.com> · 2026-08-11T13:48:14.000Z

Changed files (2) +236 -1

addon.py +76 -1
……@@ -533,7 +533,7 @@ TV_GENRES = [
533533
534534MANIFEST = {
535535 "id": "org.tabertron.vsembed",
536 "version": "0.9.1",
536 "version": "0.9.2",
537537 "name": "Tabertron VsEmbed",
538538 "description": "Self-hosted HLS extractor with TMDB browse + search, played via vidcore",
539539 "types": ["movie", "series"],
……@@ -1087,6 +1087,81 @@ def _mp4_ready(kind: str, ident: str, season: int, episode: int, height: int) ->
10871087 return False
10881088
10891089
1090# ---------------------------- library ingest --------------------------------
1091# A finished copy is not cache -- it is library content, and it is permanent. It
1092# lands in the Jellyfin library on /mnt/usb, where Jellyfin indexes it and Kodi
1093# plays it through the same plugin:// path as everything else. Seek, resume and
1094# pause all work there natively, which is what makes the muxed-fMP4 problem above
1095# stop mattering for anything we have actually fetched.
1096#
1097# The layout is the one Jellyfin identifies on, with the TMDB id stated
1098# explicitly:
1099# Shows/<Title> (<Year>) [tmdbid-<id>]/Season 02/<Title> (<Year>) - S02E07 - <Ep>.mp4
1100# Movies/<Title> (<Year>) [tmdbid-<id>]/<Title> (<Year>).mp4
1101#
1102# Stating the id matters. The 2026-07-28 library cleanup found Jellyfin's own
1103# identification is ~92% right -- fine for browsing, NOT safe to rename blindly
1104# (Bombshell matched the Hedy Lamarr documentary; The_Fall matched The Fall Guy
1105# at only 8% runtime error). That applied to retrofitting unknown files. Here the
1106# title was resolved FROM its TMDB id, so identity is asserted, not inferred, and
1107# no runtime cross-check is needed.
1108LIBRARY_ROOT = "/mnt/usb"
1109
1110# The volume is exFAT (verified 2026-08-11: findmnt -> exfat /dev/sdb1), which
1111# rejects these outright. They are REPLACED rather than dropped so a title stays
1112# readable and distinguishable -- dropping would turn "Ep: One" into "Ep One".
1113# The colon maps to U+FF1A because the library already holds
1114# `Ghost in the Shell:STAND ALONE COMPLEX` -- an existing convention, not a new
1115# invention. Slashes become "-" because a path separator reads naturally as one.
1116EXFAT_SUBSTITUTIONS = {
1117 ":": ":", "*": "*", "?": "?", '"': """,
1118 "<": "<", ">": ">", "|": "|", "/": "-", "\\": "-",
1119}
1120
1121
1122def _safe_component(name: str) -> str:
1123 """One path component, safe on exFAT and never empty.
1124
1125 Never returns "" or a value containing a separator, so a composed path
1126 cannot escape LIBRARY_ROOT no matter what TMDB (or a caller) supplies.
1127 """
1128 out = "".join(EXFAT_SUBSTITUTIONS.get(c, c) for c in name or "")
1129 out = "".join(" " if ord(c) < 32 else c for c in out)
1130 out = re.sub(r"\s+", " ", out).strip()
1131 # Leading dots hide the folder from Jellyfin entirely; trailing dots and
1132 # spaces are silently altered by exFAT/Windows, which would break the
1133 # os.replace that finalises an ingest.
1134 out = out.strip(". ")
1135 return out or "_"
1136
1137
1138def _library_path(kind: str, title: str, year: str, tmdb_id: str,
1139 season: int = 0, episode: int = 0,
1140 episode_title: str = "") -> str:
1141 """Destination for one finished copy, in Jellyfin's layout."""
1142 stem = f"{title} ({year})" if year else title
1143 folder = _safe_component(f"{stem} [tmdbid-{tmdb_id}]")
1144 if kind == "series":
1145 name = f"{_safe_component(stem)} - S{season:02d}E{episode:02d}"
1146 if episode_title:
1147 name += f" - {_safe_component(episode_title)}"
1148 return os.path.join(LIBRARY_ROOT, "Shows", folder,
1149 f"Season {season:02d}", f"{name}.mp4")
1150 return os.path.join(LIBRARY_ROOT, "Movies", folder,
1151 f"{_safe_component(stem)}.mp4")
1152
1153
1154def _part_path(dest: str) -> str:
1155 """Where an in-progress copy is written.
1156
1157 A SIBLING of its destination, not a temp dir: os.replace() cannot cross a
1158 filesystem boundary (EXDEV), and the library is a separate exFAT volume from
1159 the repo. Writing to <repo>/mp4-cache and renaming onto /mnt/usb would fail
1160 on every single ingest.
1161 """
1162 return dest + ".part"
1163
1164
10901165def _remux_to_mp4(variant_url: str, out_path: str) -> None:
10911166 """Stream-copy an HLS variant into a progressive MP4.
10921167
test_library_ingest.py +160 -0
……@@ -0,0 +1,160 @@
1"""Library ingest: fetched copies land in the Jellyfin library, not a cache.
2
3A finished copy is not cache -- it is library content, and it is permanent. It
4goes to /mnt/usb/{Shows,Movies} in the layout Jellyfin identifies on, where it is
5indexed and served like any other media and Kodi plays it through the same
6plugin:// path as the rest of the library. Seek, resume and pause all work there
7natively, which is what makes the muxed-fMP4 problem stop mattering for anything
8we have fetched.
9
10Naming carries the TMDB id explicitly ([tmdbid-138502]). The 2026-07-28 library
11work found Jellyfin's own identification is ~92% right and not safe to rename
12blindly -- but that was retrofitting unknown files. Here the title was resolved
13FROM its TMDB id, so identity is asserted rather than inferred.
14
15The volume is exFAT (verified 2026-08-11: findmnt -> exfat /dev/sdb1). That
16forbids " * / : < > ? \\ | in names, which is why the existing library holds
17`Ghost in the Shell:STAND ALONE COMPLEX` with a FULLWIDTH colon -- an existing
18convention this follows rather than invents.
19
20Run: .venv/bin/python3 -m pytest test_library_ingest.py -q
21"""
22
23import os
24
25import pytest
26
27import addon
28
29
30@pytest.fixture(autouse=True)
31def tmp_library(tmp_path, monkeypatch):
32 monkeypatch.setattr(addon, "LIBRARY_ROOT", str(tmp_path))
33 yield
34
35
36# --- character safety (exFAT) -----------------------------------------------
37
38def test_forbidden_exfat_characters_are_replaced_not_dropped():
39 """exFAT rejects " * / : < > ? \\ | outright. Replacing preserves
40 readability; dropping would turn "Ep: One" into "Ep One" and lose the
41 distinction from a real title."""
42 out = addon._safe_component('A:B/C\\D*E?F"G<H>I|J')
43 for bad in '"*/:<>?\\|':
44 assert bad not in out
45 assert "A" in out and "J" in out
46
47
48def test_colon_becomes_the_fullwidth_colon_already_used_in_the_library():
49 """Matches `Ghost in the Shell:STAND ALONE COMPLEX` on the volume today."""
50 assert addon._safe_component("Ghost in the Shell: SAC") == \
51 "Ghost in the Shell: SAC"
52
53
54def test_control_characters_and_edge_whitespace_are_stripped():
55 assert "\n" not in addon._safe_component("Two\nLines")
56 assert addon._safe_component(" padded ") == "padded"
57
58
59def test_trailing_dots_are_stripped():
60 """A component ending in '.' is rejected or silently altered on
61 exFAT/Windows, which would break the later os.replace."""
62 assert not addon._safe_component("Episode.").endswith(".")
63
64
65def test_a_component_never_becomes_empty():
66 """An empty component would collapse the path and put the file in the
67 parent directory."""
68 assert addon._safe_component("///") != ""
69 assert addon._safe_component("") != ""
70
71
72# --- series naming ----------------------------------------------------------
73
74def test_series_path_is_the_jellyfin_layout():
75 p = addon._library_path("series", "X-Men '97", "2024", "138502",
76 season=2, episode=7, episode_title="Bright Eyes")
77 rel = os.path.relpath(p, addon.LIBRARY_ROOT)
78 assert rel == os.path.join(
79 "Shows",
80 "X-Men '97 (2024) [tmdbid-138502]",
81 "Season 02",
82 "X-Men '97 (2024) - S02E07 - Bright Eyes.mp4",
83 )
84
85
86def test_season_and_episode_are_zero_padded():
87 p = addon._library_path("series", "Show", "2020", "1",
88 season=1, episode=3, episode_title="T")
89 assert "Season 01" in p and "S01E03" in p
90
91
92def test_double_digit_seasons_are_not_truncated():
93 p = addon._library_path("series", "Show", "2020", "1",
94 season=12, episode=134, episode_title="T")
95 assert "Season 12" in p and "S12E134" in p
96
97
98def test_a_missing_episode_title_still_produces_a_valid_name():
99 """TMDB does not always carry an episode title. The file must still be
100 named unambiguously -- Jellyfin keys on the SxxExx token."""
101 p = addon._library_path("series", "Show", "2020", "1",
102 season=1, episode=3, episode_title="")
103 assert p.endswith("Show (2020) - S01E03.mp4")
104
105
106def test_a_slash_in_an_episode_title_cannot_create_a_directory():
107 p = addon._library_path("series", "Show", "2020", "1", season=1, episode=1,
108 episode_title="Part 1/2")
109 assert os.path.basename(p) == "Show (2020) - S01E01 - Part 1-2.mp4"
110
111
112# --- movie naming -----------------------------------------------------------
113
114def test_movie_path_is_the_jellyfin_layout():
115 p = addon._library_path("movie", "Blade Runner", "1982", "78")
116 rel = os.path.relpath(p, addon.LIBRARY_ROOT)
117 assert rel == os.path.join(
118 "Movies",
119 "Blade Runner (1982) [tmdbid-78]",
120 "Blade Runner (1982).mp4",
121 )
122
123
124def test_a_missing_year_does_not_leave_empty_parentheses():
125 """TMDB occasionally has no release date. '( )' would both look broken and
126 change how Jellyfin parses the name."""
127 p = addon._library_path("movie", "Untitled", "", "99")
128 assert "()" not in p and "( )" not in p
129 assert "[tmdbid-99]" in p
130
131
132# --- containment ------------------------------------------------------------
133
134def test_hostile_values_cannot_escape_the_library_root():
135 for evil in ("../../etc/passwd", "..", "a/../../b", "/etc/shadow"):
136 for kind in ("series", "movie"):
137 p = os.path.realpath(addon._library_path(
138 kind, evil, "2020", evil, season=1, episode=1,
139 episode_title=evil))
140 assert p.startswith(os.path.realpath(addon.LIBRARY_ROOT) + os.sep)
141
142
143def test_the_tmdb_id_is_the_identifying_token():
144 """Two shows sharing a name and year must not share a folder."""
145 a = addon._library_path("series", "Ghosts", "2019", "95396",
146 season=1, episode=1, episode_title="P")
147 b = addon._library_path("series", "Ghosts", "2019", "87739",
148 season=1, episode=1, episode_title="P")
149 assert os.path.dirname(a) != os.path.dirname(b)
150
151
152# --- the partial file must be on the same filesystem ------------------------
153
154def test_the_part_file_is_a_sibling_of_its_destination():
155 """os.replace() across filesystems fails with EXDEV. Writing the temp file
156 into the repo's cache dir and renaming onto /mnt/usb would fail on every
157 ingest, so the partial must sit beside its destination."""
158 dest = addon._library_path("series", "Show", "2020", "1",
159 season=1, episode=1, episode_title="T")
160 assert os.path.dirname(addon._part_path(dest)) == os.path.dirname(dest)