Skip to content

Commit 0269e8d

Browse files
stevezauclaude
andauthored
fix(jellyfin): inline-validate the off-media config folder field (like Plex) (#275)
The off-media "Jellyfin config folder" settings field had no inline validation — only the Setup Health tab checked it (#274). The Plex config-folder field validates live via /api/settings/validate-plex-config-folder; this adds the Jellyfin equivalent so the field shows valid/invalid as you type. - New POST /api/settings/validate-jellyfin-config-folder (mirrors the Plex one): {exists, valid_jellyfin_structure, writable, detail, error}. Unlike Plex it isn't restricted to a fixed root (Jellyfin config is an arbitrary admin mount); admin-only, read-only stat, null-byte guarded. - Extracted looks_like_jellyfin_config_dir()/jellyfin_config_data_marker() into output/jellyfin_trickplay.py and pointed BOTH the Setup Health probe and the new endpoint at them, so the field validator and the health check can never disagree. - servers.js: the editJellyfinConfigFolder field is wired to the live validator (bound once; validates on input, on browse-pick, and on open when off-media is on) and routed to the new endpoint with a "Valid Jellyfin config folder" message. servers.html: added the invalid/valid feedback spans + has-validation to the field's input-group so messages render. Tests: endpoint valid-via-data, valid-via-plugins, not-found, wrong-folder (media dir) -> error, empty -> neutral, valid-but-read-only -> flagged. Architecture Review run; the MED (read-only branch coverage) fixed before commit. Claude-Session: https://claude.ai/code/session_01YC1Z1JFBVJ5xKi5YAqYKfc Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8ae5d02 commit 0269e8d

6 files changed

Lines changed: 206 additions & 15 deletions

File tree

media_preview_generator/output/jellyfin_trickplay.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,35 @@
7979
# — so a future Jellyfin path move is picked up automatically.
8080
_DEFAULT_TRICKPLAY_ROOT = "data/trickplay"
8181

82+
# Markers that identify a directory as Jellyfin's config dir (vs a media folder
83+
# or unrelated path). The off-media write target's first segment (``data`` by
84+
# default) is the primary one; the rest cover both the official and linuxserver
85+
# images, before or after first boot. Any ONE present is enough.
86+
_JELLYFIN_CONFIG_MARKERS = ("plugins", "config", ".jellyfin-data", "system.xml")
87+
88+
89+
def jellyfin_config_data_marker(trickplay_root: str = _DEFAULT_TRICKPLAY_ROOT) -> str:
90+
"""First path segment of the off-media trickplay root — the ``data`` dir
91+
off-media trickplay is written into (``<config>/<data_marker>/trickplay``).
92+
"""
93+
return (trickplay_root or _DEFAULT_TRICKPLAY_ROOT).replace("\\", "/").split("/", 1)[0] or "data"
94+
95+
96+
def looks_like_jellyfin_config_dir(folder: str, trickplay_root: str = _DEFAULT_TRICKPLAY_ROOT) -> bool:
97+
"""Best-effort check that ``folder`` is Jellyfin's config directory.
98+
99+
Off-media trickplay writes into ``<folder>/<trickplay_root>``; a real
100+
Jellyfin config dir (official or linuxserver image) holds that root's first
101+
segment (``data`` by default) plus ``plugins``/``config``. Any one marker is
102+
enough — the goal is to reject a media folder or unrelated path, not to be
103+
exhaustive. Shared by the Setup Health probe and the inline field validator
104+
so they can never disagree. Mirrors Plex's Media/localhost structural check.
105+
"""
106+
if not folder or not os.path.isdir(folder):
107+
return False
108+
markers = (jellyfin_config_data_marker(trickplay_root), *_JELLYFIN_CONFIG_MARKERS)
109+
return any(os.path.exists(os.path.join(folder, marker)) for marker in markers)
110+
82111

83112
def _normalize_item_guid(item_id: str) -> str:
84113
"""Normalise a Jellyfin item id to its dashed-lowercase ``"D"`` form.

media_preview_generator/servers/jellyfin.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1765,16 +1765,19 @@ def previews_readiness(self) -> dict[str, Any]:
17651765
# trickplay writes into (<config>/<trickplay_root>/…), plus
17661766
# plugins/ and config/. Pointing this at a media folder or an
17671767
# unrelated path is the common mistake — catch it here rather
1768-
# than silently writing tiles Jellyfin will never find. (It
1769-
# can't catch pointing one level too deep, e.g. <config>/data,
1770-
# since that still nests data/ + plugins/ — the markers below
1771-
# would still match; only a wrong/unrelated root is rejected.)
1772-
trickplay_root = (self.offmedia_trickplay_root or "data/trickplay").replace("\\", "/")
1773-
data_marker = trickplay_root.split("/", 1)[0] or "data"
1774-
jellyfin_markers = (data_marker, "plugins", "config", ".jellyfin-data", "system.xml")
1775-
looks_like_jellyfin = exists and any(
1776-
os.path.exists(os.path.join(config_folder, marker)) for marker in jellyfin_markers
1768+
# than silently writing tiles Jellyfin will never find. Shared
1769+
# with the inline field validator via looks_like_jellyfin_config_dir
1770+
# so the two can never disagree. (It can't catch pointing one
1771+
# level too deep, e.g. <config>/data, since that still nests
1772+
# data/ + plugins/ — only a wrong/unrelated root is rejected.)
1773+
from ..output.jellyfin_trickplay import (
1774+
jellyfin_config_data_marker,
1775+
looks_like_jellyfin_config_dir,
17771776
)
1777+
1778+
_root = self.offmedia_trickplay_root or "data/trickplay"
1779+
data_marker = jellyfin_config_data_marker(_root)
1780+
looks_like_jellyfin = looks_like_jellyfin_config_dir(config_folder, _root)
17781781
if not exists:
17791782
folder_ok = False
17801783
folder_current = "missing"

media_preview_generator/web/routes/api_settings.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,71 @@ def validate_plex_config_folder():
915915
)
916916

917917

918+
@api.route("/settings/validate-jellyfin-config-folder", methods=["POST"])
919+
@setup_or_auth_required
920+
def validate_jellyfin_config_folder():
921+
"""Inline check that a path looks like a real Jellyfin config folder.
922+
923+
The Jellyfin analog of :func:`validate_plex_config_folder` — used by the
924+
Servers > Edit Jellyfin > General tab to give the user a confident "yes
925+
this is the right folder" on the off-media config-folder field, instead of
926+
a bare "directory exists". Shares the marker logic with the Setup Health
927+
probe (``looks_like_jellyfin_config_dir``) so the two never disagree.
928+
929+
Unlike Plex (restricted to ``PLEX_DATA_ROOT``), the Jellyfin config dir is
930+
an arbitrary admin-chosen bind mount (e.g. ``/jellyfin-config``), so there
931+
is no fixed allowed root — we stat the typed path directly. Admin-only.
932+
933+
Request JSON: ``{"path": "/jellyfin-config"}``
934+
Returns: ``{"exists": bool, "valid_jellyfin_structure": bool,
935+
"writable": bool, "detail": str, "error": str|null}``
936+
"""
937+
from ...output.jellyfin_trickplay import jellyfin_config_data_marker, looks_like_jellyfin_config_dir
938+
939+
def _resp(*, exists=False, valid=False, writable=False, detail="", error=None):
940+
return jsonify(
941+
{
942+
"exists": exists,
943+
"valid_jellyfin_structure": valid,
944+
"writable": writable,
945+
"detail": detail,
946+
"error": error,
947+
}
948+
)
949+
950+
data = request.get_json() or {}
951+
raw_path = (data.get("path") or "").strip()
952+
if not raw_path:
953+
return _resp()
954+
if "\x00" in raw_path:
955+
return _resp(error="Invalid path")
956+
957+
probe = os.path.realpath(os.path.normpath(raw_path))
958+
if not os.path.isdir(probe):
959+
return _resp(error="Folder not found in this container")
960+
961+
writable = os.access(probe, os.W_OK)
962+
marker = jellyfin_config_data_marker()
963+
if not looks_like_jellyfin_config_dir(probe):
964+
return _resp(
965+
exists=True,
966+
writable=writable,
967+
error=(
968+
f"This doesn't look like Jellyfin's config dir — no '{marker}/', 'plugins/' or "
969+
"'config/' inside. Point it at the folder mounted into Jellyfin as /config "
970+
f"(containing '{marker}/'), not a media folder or unrelated path."
971+
),
972+
)
973+
if not writable:
974+
return _resp(
975+
exists=True,
976+
valid=True,
977+
writable=False,
978+
error="Folder is not writable by this container — check the mount isn't :ro and PUID/PGID",
979+
)
980+
return _resp(exists=True, valid=True, writable=True, detail="valid Jellyfin config folder")
981+
982+
918983
# ============================================================================
919984
# Setup Wizard
920985
# ============================================================================

media_preview_generator/web/static/js/servers.js

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,7 +1257,20 @@
12571257
const configFolderGroup = document.getElementById('editJellyfinConfigFolderGroup');
12581258
const configFolderInput = document.getElementById('editJellyfinConfigFolder');
12591259
if (offMediaToggle) offMediaToggle.checked = saveOffMedia;
1260-
if (configFolderInput) configFolderInput.value = jfOutput.jellyfin_config_folder || '';
1260+
if (configFolderInput) {
1261+
configFolderInput.value = jfOutput.jellyfin_config_folder || '';
1262+
configFolderInput.classList.remove('is-valid', 'is-invalid');
1263+
// Bind the inline structural validator once (mirrors the Plex
1264+
// config-folder field) so the path is checked live as you type.
1265+
if (!configFolderInput.dataset.validatorBound) {
1266+
configFolderInput.addEventListener('input', _debouncedValidatePath(configFolderInput));
1267+
configFolderInput.dataset.validatorBound = '1';
1268+
}
1269+
// Only validate up-front when off-media is actually on (the field is
1270+
// visible) — avoids a fetch against a hidden field for a server that
1271+
// has a stale stored config folder but off-media disabled.
1272+
if (configFolderInput.value && saveOffMedia) _validateLocalPathInput(configFolderInput);
1273+
}
12611274
if (configFolderGroup) configFolderGroup.classList.toggle('d-none', !(isJellyfin && saveOffMedia));
12621275
// The "Webhook & Scanner" tab now shows for ALL server types.
12631276
// Pre-fix it was hidden for non-Plex servers — closing the user's
@@ -1433,13 +1446,20 @@
14331446
// * wizardPlexConfigFolder — /setup wizard step 3 (renamed to
14341447
// avoid colliding with the partial's
14351448
// hidden input on the same page)
1436-
const useStructuralCheck =
1449+
const isPlexCfg =
14371450
input.id === 'editPlexConfigFolder' ||
14381451
input.id === 'plexConfigFolder' ||
14391452
input.id === 'wizardPlexConfigFolder';
1440-
const endpoint = useStructuralCheck
1453+
// Jellyfin off-media config folder gets the same deeper structural
1454+
// check (mirrors Plex) so the success message can confidently say
1455+
// "valid Jellyfin config folder" rather than just "path exists".
1456+
const isJellyfinCfg = input.id === 'editJellyfinConfigFolder';
1457+
const useStructuralCheck = isPlexCfg || isJellyfinCfg;
1458+
const endpoint = isPlexCfg
14411459
? '/api/settings/validate-plex-config-folder'
1442-
: '/api/settings/validate-local-path';
1460+
: isJellyfinCfg
1461+
? '/api/settings/validate-jellyfin-config-folder'
1462+
: '/api/settings/validate-local-path';
14431463
try {
14441464
const resp = await fetch(endpoint, {
14451465
method: 'POST',
@@ -1464,8 +1484,10 @@
14641484
input.classList.add('is-valid');
14651485
if (success && useStructuralCheck && data.detail) {
14661486
success.textContent = `Looks like a ${data.detail}`;
1467-
} else if (success && useStructuralCheck) {
1487+
} else if (success && isPlexCfg) {
14681488
success.textContent = 'Valid Plex config folder';
1489+
} else if (success && isJellyfinCfg) {
1490+
success.textContent = 'Valid Jellyfin config folder';
14691491
} else if (success) {
14701492
success.textContent = 'Path exists';
14711493
}
@@ -3361,6 +3383,7 @@
33613383
const start = ((cfgInput && cfgInput.value) || '').trim() || '/';
33623384
window.openFolderPicker(start, (picked) => {
33633385
cfgInput.value = picked;
3386+
_validateLocalPathInput(cfgInput);
33643387
});
33653388
});
33663389
}

media_preview_generator/web/templates/servers.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,13 @@ <h5 class="modal-title d-flex align-items-center gap-2 flex-grow-1" style="min-w
173173
<button type="button" class="info-icon ms-1" tabindex="0" data-bs-toggle="tooltip" data-bs-placement="top"
174174
title="Path inside THIS container where Jellyfin's config dir is bind-mounted read-write. This app writes trickplay into its data/trickplay subfolder. Must be mounted :rw, not :ro."><i class="bi bi-info-circle"></i></button>
175175
</label>
176-
<div class="input-group">
176+
<div class="input-group has-validation">
177177
<input type="text" id="editJellyfinConfigFolder" class="form-control" placeholder="/jellyfin-config">
178178
<button type="button" class="btn btn-outline-secondary" id="editJellyfinConfigBrowseBtn" title="Browse folders">
179179
<i class="bi bi-folder2-open"></i>
180180
</button>
181+
<div class="invalid-feedback small"></div>
182+
<div class="valid-feedback small">Valid Jellyfin config folder</div>
181183
</div>
182184
<div class="alert alert-warning small mt-2 mb-0" id="editJellyfinOffMediaWarning">
183185
<i class="bi bi-exclamation-triangle me-1"></i>With this on, the app writes to Jellyfin's config dir (not your media). Jellyfin's <code>SaveTrickplayWithMedia</code> must be <strong>off</strong> &mdash; the <strong>Setup Health</strong> tab guides you and flags the plugin + read-write mount.

tests/test_routes.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5895,6 +5895,75 @@ def test_outside_root_nonexistent_folder_suggests_mount(self, client, tmp_path,
58955895
assert "-v" in body["error"]
58965896

58975897

5898+
class TestValidateJellyfinConfigFolder:
5899+
"""Inline structural check used by Servers > Edit Jellyfin (off-media field).
5900+
Mirrors the Plex validator; shares looks_like_jellyfin_config_dir."""
5901+
5902+
def _post(self, client, path):
5903+
return client.post(
5904+
"/api/settings/validate-jellyfin-config-folder",
5905+
headers=_api_headers(),
5906+
json={"path": path},
5907+
)
5908+
5909+
def test_valid_jellyfin_config_dir(self, client, tmp_path):
5910+
cfg = tmp_path / "jellyfin-config"
5911+
(cfg / "data").mkdir(parents=True) # the data/ marker
5912+
body = self._post(client, str(cfg)).get_json()
5913+
assert body["exists"] is True
5914+
assert body["valid_jellyfin_structure"] is True
5915+
assert body["writable"] is True
5916+
assert body["error"] is None
5917+
assert "Jellyfin config folder" in body["detail"]
5918+
5919+
def test_valid_via_plugins_marker_only(self, client, tmp_path):
5920+
cfg = tmp_path / "jf-cfg"
5921+
(cfg / "plugins").mkdir(parents=True) # leniency: plugins/ alone is enough
5922+
body = self._post(client, str(cfg)).get_json()
5923+
assert body["valid_jellyfin_structure"] is True
5924+
5925+
def test_missing_folder_reports_not_found(self, client, tmp_path):
5926+
body = self._post(client, str(tmp_path / "nope")).get_json()
5927+
assert body["exists"] is False
5928+
assert body["valid_jellyfin_structure"] is False
5929+
assert "not found" in body["error"].lower()
5930+
5931+
def test_wrong_folder_rejected_with_clear_error(self, client, tmp_path):
5932+
"""A real, writable dir that's a media folder (no Jellyfin markers) must
5933+
be flagged — the exact mistake the user hit."""
5934+
media = tmp_path / "Movies"
5935+
media.mkdir()
5936+
(media / "Movie.mkv").write_bytes(b"\x00")
5937+
body = self._post(client, str(media)).get_json()
5938+
assert body["exists"] is True
5939+
assert body["valid_jellyfin_structure"] is False
5940+
assert "doesn't look like Jellyfin's config dir" in body["error"]
5941+
5942+
def test_empty_path_is_neutral(self, client):
5943+
body = self._post(client, "").get_json()
5944+
assert body["exists"] is False
5945+
assert body["error"] is None # neutral — no nag on an empty field
5946+
5947+
@pytest.mark.skipif(
5948+
hasattr(os, "geteuid") and os.geteuid() == 0,
5949+
reason="root bypasses W_OK, so a read-only dir still reports writable",
5950+
)
5951+
def test_valid_structure_but_read_only_is_flagged(self, client, tmp_path):
5952+
"""The '/config mounted :ro' mistake: structure is right but not writable.
5953+
Must report valid_structure=True, writable=False with a clear error."""
5954+
cfg = tmp_path / "jf-ro"
5955+
(cfg / "data").mkdir(parents=True)
5956+
os.chmod(cfg, 0o555)
5957+
try:
5958+
body = self._post(client, str(cfg)).get_json()
5959+
finally:
5960+
os.chmod(cfg, 0o755) # restore so tmp cleanup can remove it
5961+
assert body["exists"] is True
5962+
assert body["valid_jellyfin_structure"] is True
5963+
assert body["writable"] is False
5964+
assert "not writable" in body["error"].lower()
5965+
5966+
58985967
# ---------------------------------------------------------------------------
58995968
# Phase I5 — Per-server Plex webhook endpoints
59005969
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)