From 47ae674a204419083e1ffb11017a8ef484fa560e Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:23:29 -0500 Subject: [PATCH 1/6] Use a themed dialog for confirm prompts hx-confirm and the settings import prompt showed the browser's own confirm() box. app.js now handles htmx:confirm with a shared modal in the app's theme (data-confirm-title, data-confirm-label, red confirm button for destructive actions, Escape/backdrop cancel, focus kept in the dialog). Also styles .modal-close, which the existing modals use. --- tests/test_styled_confirm.py | 60 ++++++++++ web/static/css/plex-theme.css | 34 ++++++ web/static/js/app.js | 109 ++++++++++++++++++ web/templates/macros/pin_button.html | 2 +- web/templates/settings/backup.html | 8 +- .../settings/partials/arr_instance_card.html | 1 + .../settings/partials/path_mapping_card.html | 1 + web/templates/settings/security.html | 1 + 8 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 tests/test_styled_confirm.py diff --git a/tests/test_styled_confirm.py b/tests/test_styled_confirm.py new file mode 100644 index 00000000..5015d5e1 --- /dev/null +++ b/tests/test_styled_confirm.py @@ -0,0 +1,60 @@ +""" +Confirm prompts use the themed dialog, not the browser's confirm() box. + +app.js hooks htmx:confirm, so every hx-confirm gets pcConfirm(); plain JS calls +pcConfirm() directly. These checks keep native confirm() from coming back and +give each hx-confirm a dialog title. +""" + +import re +from pathlib import Path + +WEB = Path(__file__).resolve().parents[1] / "web" +APP_JS = (WEB / "static" / "js" / "app.js").read_text(encoding="utf-8") + + +def _sources(): + for path in (WEB / "templates").rglob("*.html"): + yield path + for path in (WEB / "static" / "js").rglob("*.js"): + if "vendor" not in path.parts: + yield path + + +_COMMENT = re.compile(r"""|\{#.*?#\}|/\*.*?\*/|(? exists).""" + offenders = [] + for path in _sources(): + source = _strip_comments(path.read_text(encoding="utf-8")) + for match in re.finditer(r"(?]|\{\{[^}]*\}\}|\{%[^%]*%\})*?\bhx-confirm=(?:[^<>]|\{\{[^}]*\}\}|\{%[^%]*%\})*>", re.S) + offenders = [] + for path in (WEB / "templates").rglob("*.html"): + source = path.read_text(encoding="utf-8") + for match in tag.finditer(source): + if "data-confirm-title=" not in match.group(0): + line = source[:match.start()].count("\n") + 1 + offenders.append(f"{path.relative_to(WEB)}:{line}") + assert not offenders, "Add data-confirm-title to: " + ", ".join(offenders) diff --git a/web/static/css/plex-theme.css b/web/static/css/plex-theme.css index 3cb47118..32a45a5c 100644 --- a/web/static/css/plex-theme.css +++ b/web/static/css/plex-theme.css @@ -1682,6 +1682,40 @@ textarea { background: inherit; } +.modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + background: none; + border: none; + border-radius: var(--plex-radius-sm); + color: var(--plex-text-muted); + cursor: pointer; +} + +.modal-close:hover { + color: var(--plex-text); + background: var(--plex-bg-hover); +} + +.modal-close svg { + width: 18px; + height: 18px; +} + +/* Shared confirm dialog (pcConfirm / hx-confirm in app.js) */ +.pc-confirm .modal-content { + max-width: 440px; + width: calc(100% - 2rem); +} + +.pc-confirm-message { + margin: 0; + line-height: 1.5; + white-space: pre-line; +} + .text-center { text-align: center; } diff --git a/web/static/js/app.js b/web/static/js/app.js index 8f0d6071..a4eb5655 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -137,3 +137,112 @@ try { localStorage.removeItem('plexcache_dashboard_verbose'); } } catch (e) { /* storage unavailable: nothing to migrate */ } + +// Themed confirm dialog. pcConfirm({title, message, confirmLabel, danger}) +// resolves true/false. Every hx-confirm in the app goes through it via the +// htmx:confirm hook below, so no page shows the browser's own confirm() box. +// Optional attributes on the hx-confirm element: data-confirm-title, +// data-confirm-label (defaults to the button's text) and data-confirm-danger +// (implied by .btn-danger) for a red confirm button. +(function() { + var modal = null, els = {}, resolver = null, opener = null; + + function build() { + modal = document.createElement('div'); + modal.className = 'modal pc-confirm'; + modal.style.display = 'none'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'pc-confirm-title'); + modal.innerHTML = + '' + + ''; + document.body.appendChild(modal); + els.title = modal.querySelector('#pc-confirm-title'); + els.message = modal.querySelector('.pc-confirm-message'); + els.ok = modal.querySelector('.pc-confirm-ok'); + els.cancel = modal.querySelector('.pc-confirm-cancel'); + els.ok.addEventListener('click', function() { close(true); }); + els.cancel.addEventListener('click', function() { close(false); }); + modal.querySelector('.modal-close').addEventListener('click', function() { close(false); }); + modal.querySelector('.modal-backdrop').addEventListener('click', function() { close(false); }); + // Capture phase so Escape/Tab stay inside the dialog while it is open. + document.addEventListener('keydown', function(e) { + if (!resolver) return; + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + close(false); + } else if (e.key === 'Tab') { + var items = modal.querySelectorAll('button'); + var first = items[0], last = items[items.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + else if (!modal.contains(document.activeElement)) { e.preventDefault(); first.focus(); } + } + }, true); + } + + function close(result) { + if (!resolver) return; + var done = resolver; + resolver = null; + modal.style.display = 'none'; + if (opener && document.contains(opener)) opener.focus(); + opener = null; + done(result); + } + + window.pcConfirm = function(opts) { + opts = opts || {}; + if (!document.body) return Promise.resolve(window.confirm(opts.message || '')); + if (!modal) build(); + if (resolver) close(false); + els.title.textContent = opts.title || 'Please confirm'; + els.message.textContent = opts.message || ''; + els.ok.textContent = opts.confirmLabel || 'Confirm'; + els.ok.className = 'btn pc-confirm-ok ' + (opts.danger ? 'btn-danger' : 'btn-primary'); + opener = document.activeElement; + modal.style.display = 'flex'; + if (typeof lucide !== 'undefined') lucide.createIcons(); + // Like the browser dialog, Enter confirms; a destructive action starts on Cancel. + (opts.danger ? els.cancel : els.ok).focus(); + return new Promise(function(resolve) { resolver = resolve; }); + }; + + function labelFor(elt, evt) { + var source = elt.getAttribute('data-confirm-label'); + if (source) return source; + var button = (evt && evt.submitter) || (elt.tagName === 'FORM' ? elt.querySelector('[type="submit"]') : elt); + var text = button ? button.textContent.replace(/\s+/g, ' ').trim() : ''; + return text && text.length <= 30 ? text : 'Confirm'; + } + + document.addEventListener('htmx:confirm', function(e) { + var question = e.detail.question; + if (!question) return; + e.preventDefault(); + var elt = e.detail.elt; + var source = elt.closest('[hx-confirm]') || elt; + var button = (e.detail.triggeringEvent && e.detail.triggeringEvent.submitter) || elt; + window.pcConfirm({ + title: source.getAttribute('data-confirm-title'), + message: question, + confirmLabel: labelFor(source, e.detail.triggeringEvent), + danger: source.hasAttribute('data-confirm-danger') || + !!(button.classList && button.classList.contains('btn-danger')) + }).then(function(ok) { + if (ok) e.detail.issueRequest(true); + }); + }); +})(); diff --git a/web/templates/macros/pin_button.html b/web/templates/macros/pin_button.html index 427dbdfb..253719ed 100644 --- a/web/templates/macros/pin_button.html +++ b/web/templates/macros/pin_button.html @@ -36,7 +36,7 @@
Previous Imports // If success, prompt to reload if (html.includes('alert-success')) { setTimeout(() => { - if (confirm('Settings imported successfully. Reload page to see changes?')) { - window.location.reload(); - } + pcConfirm({ + title: 'Settings imported', + message: 'Settings imported successfully. Reload the page to see the changes?', + confirmLabel: 'Reload' + }).then(ok => { if (ok) window.location.reload(); }); }, 500); } } catch (error) { diff --git a/web/templates/settings/partials/arr_instance_card.html b/web/templates/settings/partials/arr_instance_card.html index a528c591..b633454d 100644 --- a/web/templates/settings/partials/arr_instance_card.html +++ b/web/templates/settings/partials/arr_instance_card.html @@ -28,6 +28,7 @@ hx-delete="/settings/integrations/instances/{{ index }}" hx-target="#arr-instances" hx-swap="innerHTML" + data-confirm-title="Delete instance" data-confirm-label="Delete" hx-confirm="Delete instance '{{ instance.name }}'?"> diff --git a/web/templates/settings/partials/path_mapping_card.html b/web/templates/settings/partials/path_mapping_card.html index 9c5652c8..e3497d1b 100644 --- a/web/templates/settings/partials/path_mapping_card.html +++ b/web/templates/settings/partials/path_mapping_card.html @@ -20,6 +20,7 @@ hx-delete="/settings/paths/{{ index }}" hx-target="#path-mappings" hx-swap="innerHTML" + data-confirm-title="Delete path mapping" data-confirm-label="Delete" hx-confirm="Delete path mapping '{{ mapping.name }}'?"> diff --git a/web/templates/settings/security.html b/web/templates/settings/security.html index aa59910d..5f2a67ed 100644 --- a/web/templates/settings/security.html +++ b/web/templates/settings/security.html @@ -166,6 +166,7 @@

Security Settings

hx-post="/settings/security/logout-all" hx-target="#settings-alert-container" hx-swap="innerHTML" + data-confirm-title="Sign out all sessions" data-confirm-danger hx-confirm="Sign out all active sessions? Everyone (including you) will need to log in again."> Sign Out All From fb02dc5275e1a1cdea1da375817aaba601255ab3 Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:32:45 -0500 Subject: [PATCH 2/6] Reset add forms without hx-on, which the CSP blocks htmx compiles hx-on handlers with Function(), and the Content-Security-Policy has no 'unsafe-eval', so the Integrations add form never cleared after adding an instance (EvalError in the console). A delegated htmx:afterRequest listener in app.js now resets forms marked data-reset-on-success, skipping responses retargeted to an error alert. Test keeps hx-on out of templates. --- tests/test_styled_confirm.py | 15 +++++++++++++++ web/static/js/app.js | 15 +++++++++++++++ web/templates/settings/integrations.html | 2 +- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/test_styled_confirm.py b/tests/test_styled_confirm.py index 5015d5e1..aeebcb20 100644 --- a/tests/test_styled_confirm.py +++ b/tests/test_styled_confirm.py @@ -58,3 +58,18 @@ def test_every_hx_confirm_has_a_dialog_title(): line = source[:match.start()].count("\n") + 1 offenders.append(f"{path.relative_to(WEB)}:{line}") assert not offenders, "Add data-confirm-title to: " + ", ".join(offenders) + + +def test_no_hx_on_attributes(): + """hx-on handlers are compiled with Function(), which the CSP blocks (no + 'unsafe-eval'), so they silently never run. Use a listener in app.js, e.g. + data-reset-on-success for clearing a form.""" + main_src = (WEB / "main.py").read_text(encoding="utf-8") + assert "unsafe-eval" not in main_src + offenders = [] + for path in (WEB / "templates").rglob("*.html"): + source = _strip_comments(path.read_text(encoding="utf-8")) + for match in re.finditer(r"\s(?:data-)?hx-on(?:[:\-=])", source): + line = source[:match.start()].count("\n") + 1 + offenders.append(f"{path.relative_to(WEB)}:{line}") + assert not offenders, "hx-on is blocked by the CSP: " + ", ".join(offenders) diff --git a/web/static/js/app.js b/web/static/js/app.js index a4eb5655..551a717e 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -246,3 +246,18 @@ try { }); }); })(); + +// Forms marked data-reset-on-success clear after a successful request. +// Handled here rather than with hx-on: htmx compiles hx-on handlers with +// Function(), which the Content-Security-Policy (no 'unsafe-eval') blocks. +// A response retargeted elsewhere (an error alert) leaves the input as typed. +// data-reset-clear="#selector" empties another element too. +document.addEventListener('htmx:afterRequest', function(e) { + var form = e.detail.elt; + if (!form || form.tagName !== 'FORM' || !form.hasAttribute('data-reset-on-success')) return; + if (!e.detail.successful || e.detail.xhr.getResponseHeader('HX-Retarget')) return; + form.reset(); + var clear = form.getAttribute('data-reset-clear'); + var target = clear && document.querySelector(clear); + if (target) target.innerHTML = ''; +}); diff --git a/web/templates/settings/integrations.html b/web/templates/settings/integrations.html index 43a95f64..d7bdd2cd 100644 --- a/web/templates/settings/integrations.html +++ b/web/templates/settings/integrations.html @@ -9,7 +9,7 @@

Add Instance

- +
From 50c9a6f15afaee643ba7b83d18c88cf3133e308b Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:32:45 -0500 Subject: [PATCH 3/6] Fix adding and deleting custom path mappings on the Libraries tab Delete targeted #path-mappings, which only existed on the retired Paths page, so htmx dropped the request. Add targeted #orphan-mappings, which was only rendered once a custom mapping existed, so the first one saved without appearing. The Custom Mappings section is now always present and Add/Delete re-render it, keeping every card's index current. Cards address a mapping by list index, which shifts when any mapping is deleted (including Remove on a library card). Cards now send the paths they were rendered with and the server refuses a stale edit or delete rather than acting on whichever mapping sits at that index; a library save that removes a mapping also refreshes the custom cards out-of-band. Errors show in the page alert area. --- tests/test_custom_path_mappings.py | 251 ++++++++++++++++++ web/routers/settings.py | 114 +++++--- web/templates/settings/libraries.html | 21 +- .../settings/partials/custom_mappings.html | 21 ++ .../settings/partials/path_mapping_card.html | 9 +- 5 files changed, 361 insertions(+), 55 deletions(-) create mode 100644 tests/test_custom_path_mappings.py create mode 100644 web/templates/settings/partials/custom_mappings.html diff --git a/tests/test_custom_path_mappings.py b/tests/test_custom_path_mappings.py new file mode 100644 index 00000000..5a4c9b13 --- /dev/null +++ b/tests/test_custom_path_mappings.py @@ -0,0 +1,251 @@ +"""Custom path mappings (not linked to a Plex library) on Settings → Libraries. + +Delete used to target #path-mappings, which only existed on the retired Paths +page, so htmx dropped the request. Add targeted a container that was only +rendered once a custom mapping existed. Cards address mappings by list index, +which shifts whenever a mapping is deleted, so they also send the paths they +were rendered with and the server refuses a stale card. + +Mounts only the settings router on a minimal app, backed by a real +SettingsService over a temp settings file, with Plex library discovery mocked. +""" + +import html +import json +import re +import sys +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +sys.modules.setdefault('fcntl', MagicMock()) +for _mod in [ + 'apscheduler', 'apscheduler.schedulers', 'apscheduler.schedulers.background', + 'apscheduler.triggers', 'apscheduler.triggers.cron', 'apscheduler.triggers.interval', + 'plexapi', 'plexapi.server', +]: + sys.modules.setdefault(_mod, MagicMock()) + + +def _force_real_modules(): + for name in ["web.config", "web.routers", "web.routers.settings", + "web.services", "web.services.settings_service", "web"]: + mod = sys.modules.get(name) + if isinstance(mod, MagicMock): + del sys.modules[name] + import web # noqa: F401 + import web.config # noqa: F401 + import web.routers.settings # noqa: F401 + import web.services.settings_service # noqa: F401 + + +_force_real_modules() + +_MOVIES_LIB = { + "id": 4, "title": "Movies", "type": "movie", "type_label": "Movies", + "locations": ["/data/Movies/"], +} + + +def _mapping(name, plex, real, section_id=None): + m = {"name": name, "plex_path": plex, "real_path": real, + "cache_path": "/mnt/cache" + real[len("/mnt/user"):], + "cacheable": True, "enabled": True} + if section_id is not None: + m["section_id"] = section_id + return m + + +MOVIES = _mapping("Movies", "/data/Movies/", "/mnt/user/Movies/", section_id=4) +MOVIES_UHD = _mapping("Movies UHD", "/data/UHD/", "/mnt/user/UHD/", section_id=4) +MUSIC = _mapping("Music", "/data/Music/", "/mnt/user/Music/") +AUDIOBOOKS = _mapping("Audiobooks", "/data/Books/", "/mnt/user/Books/") + + +@pytest.fixture +def make_client(tmp_path): + def make(mappings): + settings_file = tmp_path / "plexcache_settings.json" + settings_file.write_text(json.dumps({ + "PLEX_URL": "http://localhost:32400", "PLEX_TOKEN": "abc", + "valid_sections": [4], "path_mappings": mappings, "cache_dir": "/mnt/cache", + }, indent=2), encoding="utf-8") + return settings_file + + patches = [] + + def client(mappings): + settings_file = make(mappings) + p1 = patch("web.services.settings_service.SETTINGS_FILE", settings_file) + p2 = patch("web.services.settings_service.DATA_DIR", tmp_path) + p1.start(); p2.start(); patches.extend([p1, p2]) + from web.services.settings_service import SettingsService + svc = SettingsService() + svc._cached_settings = None + p3 = patch.object(svc, "get_plex_libraries", return_value=[_MOVIES_LIB]) + p3.start(); patches.append(p3) + from web.routers import settings as settings_router + p4 = patch("web.routers.settings.get_settings_service", return_value=svc) + p4.start(); patches.append(p4) + app = FastAPI() + app.include_router(settings_router.router, prefix="/settings") + return TestClient(app), svc + + yield client + for p in reversed(patches): + p.stop() + + +def _names(svc): + return [m["name"] for m in svc._load_raw()["path_mappings"]] + + +def _expected(m): + return {"expected_plex_path": m["plex_path"], "expected_real_path": m["real_path"]} + + +def _edit_form(m, **changes): + form = {"name": m["name"], "plex_path": m["plex_path"], "real_path": m["real_path"], + "cache_path": m["cache_path"], "cacheable": "on", "enabled": "on", **_expected(m)} + form.update(changes) + return form + + +# -- page wiring ------------------------------------------------------------ + +def test_every_hx_target_on_the_libraries_page_exists(make_client): + """The Delete button pointed at an element this page never renders.""" + client, _ = make_client([MOVIES, MOVIES_UHD, MUSIC]) + page = client.get("/settings/libraries").text + # The alert container lives in settings/index.html's layout. + ids = set(re.findall(r'\bid="([^"]+)"', page)) + targets = set(re.findall(r'hx-target="#([^"]+)"', page)) + assert targets, "expected hx-target attributes on the page" + missing = sorted(t for t in targets if t not in ids) + assert not missing, missing + + +def test_delete_url_carries_the_expected_paths(make_client): + """In the URL, not hx-vals: htmx 1.9 sends DELETE values in the body.""" + kids = _mapping("Kids & Family", "/data/Kids & Family/", "/mnt/user/Kids/") + client, svc = make_client([MOVIES, MUSIC, kids]) + page = client.get("/settings/libraries").text + + def delete_url(index): + found = re.search(r'hx-delete="(/settings/paths/%d\?[^"]+)"' % index, page) + return html.unescape(found.group(1)) # what the browser sends + + # The page's own URL deletes the right mapping, '&' in the path included. + r = client.delete(delete_url(2)) + assert r.status_code == 200 + assert _names(svc) == ["Movies", "Music"] + + # Music's card is now stale-safe too: after removing Music elsewhere, + # its old URL is refused instead of deleting whatever sits at index 1. + svc.delete_path_mapping(1) + svc.add_path_mapping(AUDIOBOOKS) + r = client.delete(delete_url(1)) + assert "changed since this page loaded" in r.text + assert _names(svc) == ["Movies", "Audiobooks"] + + +def test_custom_section_rendered_even_when_empty(make_client): + client, _ = make_client([MOVIES]) + page = client.get("/settings/libraries").text + assert 'id="custom-mappings"' in page + assert 'hx-target="#custom-mappings"' in page # the Add form + assert 'id="orphan-mappings"' not in page # no heading or list without cards + + +# -- add -------------------------------------------------------------------- + +def test_add_first_custom_mapping_returns_the_section(make_client): + client, svc = make_client([MOVIES]) + r = client.post("/settings/paths", data={ + "name": "Music", "plex_path": "/data/Music/", "real_path": "/mnt/user/Music/", + "cache_path": "/mnt/cache/Music/", "cacheable": "on", "enabled": "on"}) + assert r.status_code == 200 + assert r.text.lstrip().startswith('
' in r.text + assert 'hx-delete="/settings/paths/1?' in r.text + + +def test_library_save_without_removal_leaves_custom_cards_alone(make_client): + client, _ = make_client([MOVIES, MUSIC]) + form = {"name_0": "Movies", "plex_path_0": MOVIES["plex_path"], "real_path_0": MOVIES["real_path"], + "cache_path_0": MOVIES["cache_path"], "host_cache_path_0": "", "cacheable_0": "on"} + r = client.put("/settings/libraries/4/paths", data=form) + assert "hx-swap-oob" not in r.text diff --git a/web/routers/settings.py b/web/routers/settings.py index f2af8215..027d9403 100644 --- a/web/routers/settings.py +++ b/web/routers/settings.py @@ -4,6 +4,7 @@ import logging import time import uuid +from html import escape as html_escape import threading from pathlib import Path from typing import Dict, Any, List @@ -425,6 +426,48 @@ def test_user_token(request: Request, form_data: ImmutableMultiDict = Depends(pa return HTMLResponse(f'Failed: {error}') +def _custom_mappings(mappings: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Mappings not linked to a Plex library, each tagged with its list index.""" + custom = [] + for i, m in enumerate(mappings): + if m.get("section_id") is None: + m_copy = dict(m) + m_copy["_index"] = i + custom.append(m_copy) + return custom + + +def _render_custom_mappings(request: Request, mappings: List[Dict[str, Any]], oob: bool = False) -> str: + return templates.get_template("settings/partials/custom_mappings.html").render( + {"request": request, "orphan_mappings": _custom_mappings(mappings), "oob": oob}) + + +def _mapping_alert(level: str, message: str) -> HTMLResponse: + """Show a path mapping problem in the page alert area instead of the swap target.""" + return HTMLResponse( + f"
{html_escape(message)}
", + headers={"HX-Retarget": "#settings-alert-container", "HX-Reswap": "innerHTML"}, + ) + + +_STALE_MAPPING_MESSAGE = ("The path mappings changed since this page loaded. " + "Reload the page and try again.") + + +def _is_stale_mapping(mappings: List[Dict[str, Any]], index: int, + expected_plex_path, expected_real_path) -> bool: + """Cards address a mapping by list index, which shifts when another mapping + is deleted. The card also sends the paths it was rendered with, so a stale + card is refused instead of acting on whichever mapping now sits at that index.""" + if expected_plex_path is None and expected_real_path is None: + return False + if not 0 <= index < len(mappings): + return True + current = mappings[index] + return ((expected_plex_path is not None and current.get("plex_path") != expected_plex_path) + or (expected_real_path is not None and current.get("real_path") != expected_real_path)) + + @router.get("/paths", response_class=HTMLResponse) def settings_paths(request: Request): """Path mappings tab — redirects to Libraries tab""" @@ -469,19 +512,10 @@ def add_path_mapping( success = settings_service.add_path_mapping(mapping) if success: - # Return the new mapping card with its index - mappings = settings_service.get_path_mappings() - index = len(mappings) - 1 - return templates.TemplateResponse( - request, - "settings/partials/path_mapping_card.html", - { - "mapping": mapping, - "index": index, - } - ) + # Re-render the whole Custom Mappings section (it may not exist yet) + return HTMLResponse(_render_custom_mappings(request, settings_service.get_path_mappings())) else: - return HTMLResponse("
Failed to add mapping
") + return _mapping_alert("error", "Failed to add mapping") @router.put("/paths/{index}", response_class=HTMLResponse) @@ -494,11 +528,17 @@ def update_path_mapping( cache_path: str = Form(""), host_cache_path: str = Form(""), cacheable: str = Form(None), - enabled: str = Form(None) + enabled: str = Form(None), + expected_plex_path: str = Form(None), + expected_real_path: str = Form(None), ): """Update an existing path mapping""" settings_service = get_settings_service() + if _is_stale_mapping(settings_service.get_path_mappings(), index, + expected_plex_path, expected_real_path): + return _mapping_alert("warning", _STALE_MAPPING_MESSAGE) + # Non-blocking: log a warning if the cache_path looks risky (issue #136). cache_path_warning = settings_service.warn_cache_path(cache_path) if cache_path_warning: @@ -529,26 +569,30 @@ def update_path_mapping( } ) else: - return HTMLResponse("
Failed to update mapping
") + return _mapping_alert("error", "Failed to update mapping") @router.delete("/paths/{index}", response_class=HTMLResponse) -def delete_path_mapping(request: Request, index: int): - """Delete a path mapping and return the updated list""" +def delete_path_mapping( + request: Request, + index: int, + expected_plex_path: str = Query(None), + expected_real_path: str = Query(None), +): + """Delete a custom path mapping and return the Custom Mappings section""" settings_service = get_settings_service() + if _is_stale_mapping(settings_service.get_path_mappings(), index, + expected_plex_path, expected_real_path): + return _mapping_alert("warning", _STALE_MAPPING_MESSAGE) + success = settings_service.delete_path_mapping(index) if success: - # Return the full updated list with fresh indices - mappings = settings_service.get_path_mappings() - return templates.TemplateResponse( - request, - "settings/partials/path_mappings_list.html", - {"mappings": mappings} - ) + # Fresh indices for every remaining card + return HTMLResponse(_render_custom_mappings(request, settings_service.get_path_mappings())) else: - return HTMLResponse("
Failed to delete mapping
") + return _mapping_alert("error", "Failed to delete mapping") # ============================================================================= @@ -573,15 +617,13 @@ def settings_libraries(request: Request): # Group mappings by section_id library_mappings = {} # section_id -> list of mappings (with _index) - orphan_mappings = [] # mappings without section_id for i, m in enumerate(mappings): - m_copy = dict(m) - m_copy["_index"] = i sid = m.get("section_id") if sid is not None: + m_copy = dict(m) + m_copy["_index"] = i library_mappings.setdefault(sid, []).append(m_copy) - else: - orphan_mappings.append(m_copy) + orphan_mappings = _custom_mappings(mappings) # Build library cards library_cards = [] @@ -760,11 +802,15 @@ async def update_library_paths(request: Request, section_id: int): settings_service._rebuild_valid_sections(raw) settings_service._save_raw(raw) + # Deleting shifted the list indices the Custom Mappings cards address, + # so refresh that section too (out-of-band). + custom_refresh = _render_custom_mappings(request, all_mappings, oob=True) if indices_to_delete else "" + # Re-render the full library card libraries = settings_service.get_plex_libraries() library = next((lib for lib in libraries if lib["id"] == section_id), None) if not library: - return HTMLResponse("
Saved
") + return HTMLResponse("
Saved
" + custom_refresh) lib_maps = [] for i, m in enumerate(all_mappings): @@ -780,11 +826,9 @@ async def update_library_paths(request: Request, section_id: int): "has_mappings": bool(lib_maps), } - return templates.TemplateResponse( - request, - "settings/partials/library_card.html", - {"card": card} - ) + card_html = templates.get_template("settings/partials/library_card.html").render( + {"request": request, "card": card}) + return HTMLResponse(card_html + custom_refresh) @router.put("/libraries/paths/{index}", response_class=HTMLResponse) diff --git a/web/templates/settings/libraries.html b/web/templates/settings/libraries.html index c7d4784c..39f064d5 100644 --- a/web/templates/settings/libraries.html +++ b/web/templates/settings/libraries.html @@ -22,22 +22,7 @@

Plex Libraries

{% endfor %}
- {% if orphan_mappings %} -
-

- - Custom Mappings -

-

- These path mappings are not linked to a Plex library. They may have been created manually or before library linking was available. -

-
- {% for mapping in orphan_mappings %} - {% set index = mapping._index %} - {% include "settings/partials/path_mapping_card.html" %} - {% endfor %} -
- {% endif %} + {% include "settings/partials/custom_mappings.html" %}
@@ -46,7 +31,9 @@

Add Custom Path Mapping - +
diff --git a/web/templates/settings/partials/custom_mappings.html b/web/templates/settings/partials/custom_mappings.html new file mode 100644 index 00000000..ea29a376 --- /dev/null +++ b/web/templates/settings/partials/custom_mappings.html @@ -0,0 +1,21 @@ +{# Custom path mappings (not linked to a Plex library) on the Libraries tab. + Always rendered, even when empty, so Add has a target. Add and Delete + re-render the whole section, so every card's index stays current. #} +
+ {% if orphan_mappings %} +
+

+ + Custom Mappings +

+

+ These path mappings are not linked to a Plex library. They may have been created manually or before library linking was available. +

+
+ {% for mapping in orphan_mappings %} + {% set index = mapping._index %} + {% include "settings/partials/path_mapping_card.html" %} + {% endfor %} +
+ {% endif %} +
diff --git a/web/templates/settings/partials/path_mapping_card.html b/web/templates/settings/partials/path_mapping_card.html index e3497d1b..1111e38e 100644 --- a/web/templates/settings/partials/path_mapping_card.html +++ b/web/templates/settings/partials/path_mapping_card.html @@ -17,9 +17,9 @@
diff --git a/web/templates/settings/integrations.html b/web/templates/settings/integrations.html index d7bdd2cd..9799584c 100644 --- a/web/templates/settings/integrations.html +++ b/web/templates/settings/integrations.html @@ -9,7 +9,7 @@

Add Instance

- +
diff --git a/web/templates/settings/partials/users_sync_result.html b/web/templates/settings/partials/users_sync_result.html index 4d393f69..16a492eb 100644 --- a/web/templates/settings/partials/users_sync_result.html +++ b/web/templates/settings/partials/users_sync_result.html @@ -1,9 +1,14 @@ - + {% if success %} +{% else %} + {% endif %} diff --git a/web/templates/settings/schedule.html b/web/templates/settings/schedule.html index 03657f42..e51d0458 100644 --- a/web/templates/settings/schedule.html +++ b/web/templates/settings/schedule.html @@ -394,14 +394,14 @@

Schedule Settings

.then(data => { validationDiv.className = 'cron-validation ' + (data.valid ? 'valid' : 'invalid'); - if (data.valid) { - let html = ' ' + data.message; - if (data.next_runs && data.next_runs.length > 0) { - html += '
Next runs: ' + data.next_runs.join(', ') + '
'; - } - validationDiv.innerHTML = html; - } else { - validationDiv.innerHTML = ' ' + data.message; + // The message can echo the expression back, so it goes in as text. + validationDiv.innerHTML = ' '; + validationDiv.appendChild(document.createTextNode(data.message)); + if (data.valid && data.next_runs && data.next_runs.length > 0) { + const nextRuns = document.createElement('div'); + nextRuns.className = 'next-runs'; + nextRuns.textContent = 'Next runs: ' + data.next_runs.join(', '); + validationDiv.appendChild(nextRuns); } lucide.createIcons(); From f0620339e1fbef584fc225d905880f7c36d027d1 Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:08:57 -0500 Subject: [PATCH 5/6] Navigate to Setup instead of swapping it into the page When setup became incomplete while a page was open, the banner's htmx poll was redirected to /setup, the browser followed it, and htmx swapped the whole Setup page into the status pill on top of the current page. HTMX requests now get HX-Redirect so the page navigates, as auth_middleware already does for an expired login. --- tests/test_setup_redirect_htmx.py | 53 +++++++++++++++++++++++++++++++ web/main.py | 5 +++ 2 files changed, 58 insertions(+) create mode 100644 tests/test_setup_redirect_htmx.py diff --git a/tests/test_setup_redirect_htmx.py b/tests/test_setup_redirect_htmx.py new file mode 100644 index 00000000..125b0f3f --- /dev/null +++ b/tests/test_setup_redirect_htmx.py @@ -0,0 +1,53 @@ +"""Setup redirect for HTMX requests. + +When setup becomes incomplete while a page is open (e.g. the Plex token is +cleared), the banner keeps polling /api/operation-banner. A plain redirect was +followed by the browser and htmx swapped the whole Setup page into the pill, +on top of the current page. HTMX requests now get HX-Redirect so the page +navigates, as auth_middleware already does for login. +""" + +import sys +import types +from unittest.mock import MagicMock, patch + +for _mod in ("fcntl", "pwd", "grp"): + sys.modules.setdefault(_mod, types.ModuleType(_mod)) +for _mod in ["apscheduler", "apscheduler.schedulers", "apscheduler.schedulers.background", + "apscheduler.triggers", "apscheduler.triggers.cron", "apscheduler.triggers.interval"]: + sys.modules.setdefault(_mod, MagicMock()) + + +def _force_real_modules(): + for name in list(sys.modules): + if (name == "web" or name.startswith("web.")) and isinstance(sys.modules[name], MagicMock): + del sys.modules[name] + import web.config # noqa: F401 + import web.main # noqa: F401 + + +_force_real_modules() + +from fastapi.testclient import TestClient # noqa: E402 + + +def _client(): + import web.main as main + return TestClient(main.app), main + + +def test_htmx_request_gets_hx_redirect_not_a_page(): + client, main = _client() + with patch.object(main.setup, "is_setup_complete", return_value=False): + r = client.get("/api/operation-banner", headers={"HX-Request": "true"}, follow_redirects=False) + assert r.status_code == 200 + assert r.headers["HX-Redirect"] == "/setup" + assert r.text == "" + + +def test_normal_navigation_still_redirects(): + client, main = _client() + with patch.object(main.setup, "is_setup_complete", return_value=False): + r = client.get("/settings/cache", follow_redirects=False) + assert r.status_code == 307 + assert r.headers["location"] == "/setup" diff --git a/web/main.py b/web/main.py index b538f89c..d26ccc7e 100644 --- a/web/main.py +++ b/web/main.py @@ -229,6 +229,11 @@ async def setup_redirect_middleware(request: Request, call_next): # During setup, also allow path browsing for the wizard if path.startswith("/api/browse") or path.startswith("/api/validate-path"): return await call_next(request) + # HTMX requests (e.g. the banner poll): a plain redirect would be + # followed and the whole Setup page swapped into the element. Make + # htmx navigate the page instead, as auth_middleware does for login. + if request.headers.get("HX-Request") == "true": + return Response(status_code=200, headers={"HX-Redirect": "/setup"}) return RedirectResponse(url="/setup", status_code=307) return await call_next(request) From d9106041b8ceb33bee51954729d3902ea881b9d1 Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:17:08 -0500 Subject: [PATCH 6/6] Align mixed badges and size badge icons as svg Lucide renders , so .badge i never applied and badge icons were 24px. An icon badge was taller than a text-only one beside it, and with baseline alignment the text-only badge sat lower (Recently Added: 'Returns when watched' + 'OnDeck'). Size .badge svg at the intended 12px and align badges by the middle. Also reload the settings/api routers in two test modules when an earlier module bound them to a mocked web.config, so they pass in any order. --- tests/test_badge_icons.py | 28 +++++++++++++++++++++++++++ tests/test_settings_save_fixes.py | 12 +++++++++--- tests/test_size_setting_validation.py | 9 +++++++-- web/static/css/plex-theme.css | 7 ++++++- 4 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 tests/test_badge_icons.py diff --git a/tests/test_badge_icons.py b/tests/test_badge_icons.py new file mode 100644 index 00000000..667b2503 --- /dev/null +++ b/tests/test_badge_icons.py @@ -0,0 +1,28 @@ +"""Badge icon sizing and alignment. + +Lucide replaces with , so `.badge i` never applied and +badge icons rendered at 24px. That made icon badges taller, and with baseline +alignment a text-only badge beside one (Recently Added: "Returns when watched" ++ "OnDeck") sat lower. +""" + +import re +from pathlib import Path + +CSS = (Path(__file__).resolve().parents[1] / "web" / "static" / "css" / "plex-theme.css").read_text(encoding="utf-8") + + +def _rule(selector): + match = re.search(r"(?m)^" + re.escape(selector) + r"\s*\{([^}]*)\}", CSS) + assert match, selector + return match.group(1) + + +def test_badge_icons_are_sized_as_svg(): + body = _rule(".badge svg") + assert "width: 12px" in body and "height: 12px" in body + assert not re.search(r"(?m)^\.badge i\s*\{", CSS) + + +def test_badges_align_by_middle(): + assert "vertical-align: middle" in _rule(".badge") diff --git a/tests/test_settings_save_fixes.py b/tests/test_settings_save_fixes.py index b3c02a0f..337cefd0 100644 --- a/tests/test_settings_save_fixes.py +++ b/tests/test_settings_save_fixes.py @@ -30,9 +30,15 @@ def _force_real_modules(): "web.services", "web.services.settings_service", "web"]: if isinstance(sys.modules.get(name), MagicMock): del sys.modules[name] - import web.config # noqa: F401 - import web.routers.settings # noqa: F401 - import web.routers.api # noqa: F401 + import importlib + import web.config + import web.routers.settings + import web.routers.api + # A router imported earlier, while web.config was a mock, keeps that mock + # as its `templates`; reload it against the real one. + for router in (web.routers.settings, web.routers.api): + if router.templates is not web.config.templates: + importlib.reload(router) _force_real_modules() diff --git a/tests/test_size_setting_validation.py b/tests/test_size_setting_validation.py index d83abe58..571c92df 100644 --- a/tests/test_size_setting_validation.py +++ b/tests/test_size_setting_validation.py @@ -49,8 +49,13 @@ def _force_real_modules(): "web.services", "web.services.settings_service", "web"]: if isinstance(sys.modules.get(name), MagicMock): del sys.modules[name] - import web.config # noqa: F401 - import web.routers.settings # noqa: F401 + import importlib + import web.config + import web.routers.settings + # A router imported earlier, while web.config was a mock, keeps that mock + # as its `templates`; reload it against the real one. + if web.routers.settings.templates is not web.config.templates: + importlib.reload(web.routers.settings) for _mod in ['apscheduler', 'apscheduler.schedulers', 'apscheduler.schedulers.background', diff --git a/web/static/css/plex-theme.css b/web/static/css/plex-theme.css index 32a45a5c..605dfbbe 100644 --- a/web/static/css/plex-theme.css +++ b/web/static/css/plex-theme.css @@ -828,11 +828,16 @@ body { border-radius: 6px; font-size: 0.8rem; font-weight: 500; + /* Badges with and without an icon sit side by side (status + OnDeck); + baseline alignment dropped the text-only one to the icon's bottom edge. */ + vertical-align: middle; } -.badge i { +/* Lucide replaces with , so size the svg */ +.badge svg { width: 12px; height: 12px; + flex-shrink: 0; } .badge-success {