diff --git a/core/system_utils.py b/core/system_utils.py index e2f1157..55f311d 100644 --- a/core/system_utils.py +++ b/core/system_utils.py @@ -6,6 +6,7 @@ import os import platform import posixpath +import re import shutil import subprocess import atexit @@ -319,27 +320,52 @@ def parse_size_bytes(size_str: str) -> int: if not size_str or size_str.strip() == "0": return 0 size_str = size_str.strip().upper() + units = (('TB', 1024**4), ('GB', 1024**3), ('MB', 1024**2), + ('T', 1024**4), ('G', 1024**3), ('M', 1024**2)) + number, multiplier = size_str, 1024**3 # Bare numbers default to GB + for suffix, factor in units: + if size_str.endswith(suffix): + number, multiplier = size_str[:-len(suffix)], factor + break try: - if size_str.endswith('TB'): - return int(float(size_str[:-2]) * 1024**4) - elif size_str.endswith('GB'): - return int(float(size_str[:-2]) * 1024**3) - elif size_str.endswith('MB'): - return int(float(size_str[:-2]) * 1024**2) - elif size_str.endswith('T'): - return int(float(size_str[:-1]) * 1024**4) - elif size_str.endswith('G'): - return int(float(size_str[:-1]) * 1024**3) - elif size_str.endswith('M'): - return int(float(size_str[:-1]) * 1024**2) - else: - return int(float(size_str) * 1024**3) # Default to GB + value = int(float(number) * multiplier) except ValueError: + value = None + # Callers treat a negative result as a percentage (see + # ConfigManager._parse_cache_limit), so a negative size must never get through. + if value is None or value < 0: # 0 means "no limit" to every caller, so an unparseable value silently # removes the cap the user thought they set. Say so; callers that can # name the setting add their own message on top. logging.warning(f"Could not read '{size_str}' as a size. Expected e.g. 500GB, 1.5T or 250. Treating as unset.") return 0 + return value + + +def size_setting_error(value: str, allow_percent: bool = True) -> Optional[str]: + """Why a size setting (cache_limit, min_free_space, ...) can't be read, or None. + + Accepts the same formats as parse_size_bytes() and, when allow_percent, + a percentage from just above 0 to 100. Empty or "0" means unset and is valid. + Used by the Settings page to refuse a value the engine would ignore. + """ + text = (value or "").strip() + if not text or text == "0": + return None + hint = "Use a size like 500GB or 1.5T" + (", or a percentage like 75%" if allow_percent else "") + if text.endswith("%"): + if not allow_percent: + return f"'{text}' is a percentage; this needs a size. {hint}." + try: + percent = float(text[:-1]) + except ValueError: + return f"'{text}' isn't a percentage. {hint}." + if not 0 < percent <= 100: + return f"'{text}' must be more than 0% and at most 100%." + return None + if not re.fullmatch(r"\d+(\.\d+)?\s*(TB|GB|MB|T|G|M)?", text, re.IGNORECASE): + return f"'{text}' isn't a size. {hint}." + return None def format_bytes(bytes_value: int) -> str: diff --git a/tests/test_badge_icons.py b/tests/test_badge_icons.py new file mode 100644 index 0000000..667b250 --- /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_custom_path_mappings.py b/tests/test_custom_path_mappings.py new file mode 100644 index 0000000..5a4c9b1 --- /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/tests/test_settings_import_redaction.py b/tests/test_settings_import_redaction.py new file mode 100644 index 0000000..81e0215 --- /dev/null +++ b/tests/test_settings_import_redaction.py @@ -0,0 +1,179 @@ +"""Settings Import/Export: redacted exports, import safety, validation. + +An export "without sensitive data" replaces secrets with placeholders +("http://[REDACTED]:32400", blank tokens, anonymised users). Importing one, +even in Merge mode, wrote those placeholders over the real values: the Plex +token went blank (the app fell back to Setup) and the login password hash +became "[REDACTED]". Imports now keep the current installation's values for +everything a redacted export hides. +""" + +import json +import re +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +for _mod in ["fcntl", "pwd", "grp", '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.services", "web.services.settings_service", "web"]: + if isinstance(sys.modules.get(name), MagicMock): + del sys.modules[name] + import web.config # noqa: F401 + import web.services.settings_service # noqa: F401 + + +_force_real_modules() + +ROOT = Path(__file__).resolve().parents[1] + +CURRENT = { + "PLEX_URL": "http://192.168.1.10:32400", + "PLEX_TOKEN": "real-plex-token", + "plexcache_client_id": "client-123", + "remote_watchlist_rss_url": "https://rss.plex.tv/abcdef-guid", + "webhook_url": "https://discord.com/api/webhooks/1/secret", + "auth_enabled": True, + "auth_password_username": "admin", + "auth_password_hash": "hash-value", + "auth_password_salt": "salt-value", + "auth_admin_plex_id": 42, + "auth_admin_username": "owner", + "api_key": "api-key-value", + "users": [ + {"title": "owner", "username": "owner", "is_admin": True, "is_local": True}, + {"title": "Alex", "username": "alex_plex", "is_local": False, "token": "alex-token", "uuid": "u-1"}, + ], + "arr_instances": [ + {"name": "Sonarr", "type": "sonarr", "url": "http://sonarr:8989", "api_key": "sonarr-key", "enabled": True}, + {"name": "Radarr", "type": "radarr", "url": "http://radarr:7878", "api_key": "radarr-key", "enabled": True}, + ], + "days_to_monitor": 30, + "cache_limit": "75%", +} + + +@pytest.fixture +def svc(tmp_path): + settings_file = tmp_path / "plexcache_settings.json" + settings_file.write_text(json.dumps(CURRENT, indent=2), encoding="utf-8") + with patch("web.services.settings_service.SETTINGS_FILE", settings_file), \ + patch("web.services.settings_service.DATA_DIR", tmp_path), \ + patch("web.dependencies.DATA_DIR", tmp_path): + from web.services.settings_service import SettingsService + service = SettingsService() + with patch.object(service, "invalidate_plex_cache"): + yield service + + +# -- export ------------------------------------------------------------------- + +def test_redacted_export_hides_secrets(svc): + out = svc.export_settings(include_sensitive=False) + text = json.dumps(out) + for secret in ("real-plex-token", "192.168.1.10", "client-123", "abcdef-guid", "discord.com", + "hash-value", "salt-value", "api-key-value", "alex-token", "alex_plex", + "sonarr-key", "radarr-key"): + assert secret not in text, secret + assert "api_key" not in out + assert out["_redacted_export"] is True + + +def test_full_export_keeps_everything_and_has_no_marker(svc): + out = svc.export_settings(include_sensitive=True) + assert out["api_key"] == "api-key-value" and out["PLEX_TOKEN"] == "real-plex-token" + assert "_redacted_export" not in out + + +# -- import ------------------------------------------------------------------- + +@pytest.mark.parametrize("merge", [True, False]) +def test_importing_a_redacted_export_keeps_current_secrets(svc, merge): + shared = svc.export_settings(include_sensitive=False) + shared["days_to_monitor"] = 45 # an ordinary setting still applies + result = svc.import_settings(shared, merge=merge) + assert result["success"] and result["redacted"] + + raw = svc._load_raw() + for key in ("PLEX_URL", "PLEX_TOKEN", "plexcache_client_id", "remote_watchlist_rss_url", + "webhook_url", "auth_password_username", "auth_password_hash", "auth_password_salt", + "auth_admin_plex_id", "auth_admin_username", "api_key", "users"): + assert raw[key] == CURRENT[key], key + assert [i["api_key"] for i in raw["arr_instances"]] == ["sonarr-key", "radarr-key"] + assert raw["days_to_monitor"] == 45 + assert "_redacted_export" not in raw + assert "[REDACTED]" not in json.dumps(raw) + + +def test_legacy_redacted_export_without_marker_is_recognised(svc): + shared = svc.export_settings(include_sensitive=False) + del shared["_redacted_export"] # exports made before the marker + svc.import_settings(shared, merge=True) + raw = svc._load_raw() + assert raw["PLEX_TOKEN"] == "real-plex-token" + assert raw["auth_password_hash"] == "hash-value" + + +def test_full_backup_round_trips_exactly(svc): + backup = svc.export_settings(include_sensitive=True) + svc.import_settings(dict(backup, days_to_monitor=99), merge=True) + result = svc.import_settings(backup, merge=False) + assert result["success"] and not result["redacted"] + assert svc._load_raw() == CURRENT + + +def test_new_arr_instance_in_redacted_import_has_no_key(svc): + shared = svc.export_settings(include_sensitive=False) + shared["arr_instances"].append({"name": "Sonarr 4K", "type": "sonarr", "url": "http://sonarr4k:8989", + "api_key": "", "enabled": True}) + svc.import_settings(shared, merge=True) + keys = [i["api_key"] for i in svc._load_raw()["arr_instances"]] + assert keys == ["sonarr-key", "radarr-key", ""] + + +# -- validation ----------------------------------------------------------------- + +def test_validation_of_a_full_backup_has_no_false_warnings(svc): + report = svc.validate_import_settings(svc.export_settings(include_sensitive=True)) + assert report["valid"] + joined = " ".join(report["warnings"]) + assert "Unknown settings" not in joined + assert "cache_limit" not in joined # 75% is valid + + +def test_validation_explains_a_redacted_export(svc): + report = svc.validate_import_settings(svc.export_settings(include_sensitive=False)) + joined = " ".join(report["warnings"]) + assert "exported without sensitive data" in joined + assert "Missing PLEX_TOKEN" not in joined and "missing tokens" not in joined + + +def test_validation_flags_unreadable_sizes(svc): + report = svc.validate_import_settings({"PLEX_URL": "u", "PLEX_TOKEN": "t", "cache_limit": "banana"}) + assert any(w.startswith("cache_limit:") for w in report["warnings"]) + + +def test_every_real_settings_key_is_known(): + """Keys the engine reads or the app writes must not be reported as unknown.""" + from web.services.settings_service import KNOWN_SETTINGS_KEYS + config_src = (ROOT / "core" / "config.py").read_text(encoding="utf-8") + keys = set(re.findall(r"settings_data\.get\(['\"]([A-Za-z_]+)['\"]", config_src)) + for rel in ("web/services/settings_service.py", "web/services/auth_service.py", + "web/services/scheduler_service.py", "web/routers/settings.py", "web/routers/setup.py", + "core/config.py", "core/app.py"): + src = (ROOT / rel).read_text(encoding="utf-8") + keys |= set(re.findall( + r"(?:raw|settings|data|settings_data|save_data|self\.settings_data|current)" + r"\[\s*['\"]([A-Za-z_]+)['\"]\s*\]\s*=", src)) + service_src = (ROOT / "web" / "services" / "settings_service.py").read_text(encoding="utf-8") + keys |= set(re.findall(r'"\w+":\s*\("([A-Za-z_]+)",', service_src)) # save_* field mappings + missing = sorted(keys - KNOWN_SETTINGS_KEYS) + assert not missing, f"Add to KNOWN_SETTINGS_KEYS: {missing}" diff --git a/tests/test_settings_save_fixes.py b/tests/test_settings_save_fixes.py new file mode 100644 index 0000000..337cefd --- /dev/null +++ b/tests/test_settings_save_fixes.py @@ -0,0 +1,140 @@ +"""Settings saves found wrong during a full UI pass. + +- Users: the admin's OnDeck/Watchlist toggles render disabled (always + included), and a disabled checkbox is never submitted, so every save wrote + skip_ondeck/skip_watchlist = True for the admin. +- Users: a failed Sync replaced the users table with the error. +- Integrations: Add appended a card next to the "none configured" placeholder. +- Schedule: an invalid cron expression saved with "success", then failed when + the job was created, leaving a schedule that showed Enabled but never ran. +- Import: "Settings merged with successfully". +""" + +import json +import sys +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +for _mod in ["fcntl", "pwd", "grp", '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.routers.api", + "web.services", "web.services.settings_service", "web"]: + if isinstance(sys.modules.get(name), MagicMock): + del sys.modules[name] + 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() + +USERS = [ + {"title": "owner", "is_admin": True, "is_local": True}, + {"title": "Alex", "is_admin": False, "is_local": True}, +] + + +@pytest.fixture +def client(tmp_path): + settings_file = tmp_path / "plexcache_settings.json" + settings_file.write_text(json.dumps({ + "PLEX_URL": "http://x:32400", "PLEX_TOKEN": "t", "users": USERS, "arr_instances": [], + }, indent=2), encoding="utf-8") + with patch("web.services.settings_service.SETTINGS_FILE", settings_file), \ + patch("web.services.settings_service.DATA_DIR", tmp_path): + from web.services.settings_service import SettingsService + from web.routers import settings as settings_router, api as api_router + svc = SettingsService() + scheduler = MagicMock() + app = FastAPI() + app.include_router(settings_router.router, prefix="/settings") + app.include_router(api_router.router, prefix="/api") + with patch("web.routers.settings.get_settings_service", return_value=svc), \ + patch("web.routers.api.get_scheduler_service", return_value=scheduler): + yield TestClient(app), svc, scheduler + + +def _users(svc): + return {u["title"]: u for u in svc._load_raw()["users"]} + + +def test_saving_users_keeps_the_admin_included(client): + test_client, svc, _ = client + # The browser sends nothing for the admin's disabled toggles. + test_client.put("/settings/users", data={"users_toggle": "on", "include_ondeck_Alex": "on"}) + users = _users(svc) + assert users["owner"]["skip_ondeck"] is False and users["owner"]["skip_watchlist"] is False + assert users["Alex"]["skip_ondeck"] is False and users["Alex"]["skip_watchlist"] is True + + +def test_failed_sync_keeps_the_users_table(client): + test_client, svc, _ = client + with patch.object(svc, "sync_users_from_plex", return_value={"success": False, "error": "Plex unreachable"}): + r = test_client.post("/settings/users/sync") + assert "Sync failed: Plex unreachable" in r.text + assert "alert-error" in r.text + assert "owner" in r.text and "Alex" in r.text # the table is still there + + +def test_adding_an_instance_rerenders_the_list(client): + test_client, svc, _ = client + r = test_client.post("/settings/integrations/instances", + data={"name": "Sonarr", "arr_type": "sonarr", "url": "http://s:8989", + "api_key": "k", "enabled": "on"}) + assert "No Sonarr/Radarr instances configured" not in r.text + assert "Delete instance 'Sonarr'" in r.text or "Delete instance 'Sonarr'" in r.text + r = test_client.post("/settings/integrations/instances", + data={"name": "Radarr", "arr_type": "radarr", "url": "http://r:7878", "api_key": "k"}) + assert "/settings/integrations/instances/0" in r.text and "/settings/integrations/instances/1" in r.text + + +def test_invalid_cron_is_not_saved(client): + test_client, _, scheduler = client + scheduler.validate_cron.return_value = {"valid": False, "message": "Wrong number of fields; got 4, expected 5", + "next_runs": []} + r = test_client.post("/api/settings/schedule", data={"enabled": "on", "schedule_type": "cron", + "cron_expression": "99 * * *"}) + assert "Not saved." in r.text and "Wrong number of fields" in r.text + scheduler.update_config.assert_not_called() + + +def test_valid_cron_is_saved(client): + test_client, _, scheduler = client + scheduler.validate_cron.return_value = {"valid": True, "message": "Valid cron expression", "next_runs": []} + scheduler.update_config.return_value = {"success": True} + r = test_client.post("/api/settings/schedule", data={"enabled": "on", "schedule_type": "cron", + "cron_expression": "15 3 * * 1-5"}) + assert "saved successfully" in r.text + scheduler.update_config.assert_called_once() + + +def test_interval_schedule_skips_cron_validation(client): + test_client, _, scheduler = client + scheduler.update_config.return_value = {"success": True} + test_client.post("/api/settings/schedule", data={"enabled": "on", "schedule_type": "interval", + "interval_hours": "6", "cron_expression": "garbage"}) + scheduler.validate_cron.assert_not_called() + scheduler.update_config.assert_called_once() + + +def test_import_message_wording(client): + test_client, _, _ = client + backup = json.dumps({"PLEX_URL": "http://x:32400", "PLEX_TOKEN": "t", "days_to_monitor": 7}) + r = test_client.post("/settings/import-export/import", data={"import_mode": "merge"}, + files={"settings_file": ("backup.json", backup, "application/json")}) + assert "Settings merged successfully" in r.text diff --git a/tests/test_setup_redirect_htmx.py b/tests/test_setup_redirect_htmx.py new file mode 100644 index 0000000..125b0f3 --- /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/tests/test_size_setting_validation.py b/tests/test_size_setting_validation.py new file mode 100644 index 0000000..571c92d --- /dev/null +++ b/tests/test_size_setting_validation.py @@ -0,0 +1,106 @@ +"""Size settings (Cache Drive Size, Cache Limit, Min Free Space, PlexCache Quota). + +The engine reads an unparseable size as "no limit", so the Cache tab saved +"banana" with a success message and the cap silently did nothing. A negative +size was worse: callers treat negative bytes as a percentage, so "-5GB" Min +Free Space became billions of percent and stopped all caching. The Settings +page now refuses both, and parse_size_bytes() never returns a negative. +""" + +import sys +from unittest.mock import MagicMock + +import pytest + +for _mod in ("fcntl", "pwd", "grp"): + sys.modules.setdefault(_mod, MagicMock()) + +from core.system_utils import parse_size_bytes, size_setting_error # noqa: E402 + + +@pytest.mark.parametrize("text, expected", [ + ("500GB", 500 * 1024**3), ("1.5T", int(1.5 * 1024**4)), ("250", 250 * 1024**3), + ("100MB", 100 * 1024**2), (" 2 tb ", 2 * 1024**4), ("", 0), ("0", 0), + ("banana", 0), ("-5GB", 0), ("-1", 0), +]) +def test_parse_size_bytes(text, expected): + assert parse_size_bytes(text) == expected + + +@pytest.mark.parametrize("text", ["", "0", "500GB", "1.5t", "250", "2 TB", "100mb", "75%", "7.5%", "100%"]) +def test_valid_sizes(text): + assert size_setting_error(text) is None + + +@pytest.mark.parametrize("text", ["banana", "-5GB", "5 GiB", "150%", "0%", "-5%", "abc%", "GB"]) +def test_invalid_sizes(text): + assert size_setting_error(text) + + +def test_percent_only_where_allowed(): + assert size_setting_error("75%", allow_percent=False) + assert size_setting_error("500GB", allow_percent=False) is None + + +# -- Cache tab save ----------------------------------------------------------- + +def _force_real_modules(): + for name in ["web.config", "web.routers", "web.routers.settings", + "web.services", "web.services.settings_service", "web"]: + if isinstance(sys.modules.get(name), MagicMock): + del sys.modules[name] + 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', + 'apscheduler.triggers', 'apscheduler.triggers.cron', 'apscheduler.triggers.interval', + 'plexapi', 'plexapi.server']: + sys.modules.setdefault(_mod, MagicMock()) +_force_real_modules() + + +@pytest.fixture +def client(tmp_path): + import json + from unittest.mock import patch + from fastapi import FastAPI + from fastapi.testclient import TestClient + + settings_file = tmp_path / "plexcache_settings.json" + settings_file.write_text(json.dumps({"PLEX_URL": "http://x:32400", "PLEX_TOKEN": "t", + "cache_limit": "500GB"}, indent=2), encoding="utf-8") + with patch("web.services.settings_service.SETTINGS_FILE", settings_file), \ + patch("web.services.settings_service.DATA_DIR", tmp_path): + from web.services.settings_service import SettingsService + from web.routers import settings as settings_router + svc = SettingsService() + app = FastAPI() + app.include_router(settings_router.router, prefix="/settings") + with patch("web.routers.settings.get_settings_service", return_value=svc): + yield TestClient(app), svc + + +def test_cache_save_refuses_unreadable_sizes(client): + test_client, svc = client + r = test_client.put("/settings/cache", data={"cache_limit": "banana", "min_free_space": "-5GB", + "cache_drive_size": "75%", "days_to_monitor": "30"}) + assert "Not saved." in r.text + assert "Cache Limit" in r.text and "Min Free Space" in r.text and "Cache Drive Size" in r.text + raw = svc._load_raw() + assert raw["cache_limit"] == "500GB" # unchanged + assert "days_to_monitor" not in raw # nothing from that form was saved + + +def test_cache_save_accepts_valid_sizes(client): + test_client, svc = client + r = test_client.put("/settings/cache", data={"cache_limit": "75%", "min_free_space": "50GB", + "plexcache_quota": "1.5T", "cache_drive_size": ""}) + assert "Not saved" not in r.text + raw = svc._load_raw() + assert (raw["cache_limit"], raw["min_free_space"], raw["plexcache_quota"]) == ("75%", "50GB", "1.5T") diff --git a/tests/test_styled_confirm.py b/tests/test_styled_confirm.py new file mode 100644 index 0000000..aeebcb2 --- /dev/null +++ b/tests/test_styled_confirm.py @@ -0,0 +1,75 @@ +""" +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) + + +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/main.py b/web/main.py index b538f89..d26ccc7 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) diff --git a/web/routers/api.py b/web/routers/api.py index de7298f..05d4dd3 100644 --- a/web/routers/api.py +++ b/web/routers/api.py @@ -183,6 +183,20 @@ def save_schedule_settings(request: Request, form_data: ImmutableMultiDict = Dep verbose=form_data.get("verbose") == "on", ) + # An expression APScheduler can't parse would be saved, then fail when the + # job is created, leaving a schedule that shows Enabled but never runs. + if config.schedule_type == "cron": + check = scheduler_service.validate_cron(config.cron_expression) + if not check["valid"]: + return templates.TemplateResponse( + request, + "partials/alert.html", + { + "type": "error", + "message": f"Not saved. Cron expression '{config.cron_expression}' is not valid: {check['message']}" + } + ) + result = scheduler_service.update_config(config) if result["success"]: diff --git a/web/routers/settings.py b/web/routers/settings.py index f2af821..1412a31 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 @@ -17,7 +18,7 @@ from web.config import templates, CONFIG_DIR, PLEXCACHE_PRODUCT_VERSION from web.dependencies import parse_form from web.services import get_settings_service, get_scheduler_service -from core.system_utils import get_disk_usage, detect_zfs, parse_size_bytes +from core.system_utils import get_disk_usage, detect_zfs, parse_size_bytes, size_setting_error from core.file_operations import ( PRIORITY_RANGE_ONDECK_MIN, PRIORITY_RANGE_ONDECK_MAX, @@ -292,12 +293,16 @@ def sync_users(request: Request): } ) else: + # Keep the current table under the error; it replaces the container. + user_settings = settings_service.get_user_settings() return templates.TemplateResponse( request, - "partials/alert.html", + "settings/partials/users_sync_result.html", { - "type": "error", - "message": f"Sync failed: {result['error']}" + "success": False, + "message": f"Sync failed: {result['error']}", + "users": user_settings.get("users", []), + "settings": user_settings } ) @@ -316,9 +321,11 @@ def save_user_settings(request: Request, form_data: ImmutableMultiDict = Depends # Checkbox ON = include (not skip), OFF = skip for user in users: title = user.get("title", "") - # Checkboxes: "on" if checked, absent if unchecked - include_ondeck = form_data.get(f"include_ondeck_{title}") == "on" - include_watchlist = form_data.get(f"include_watchlist_{title}") == "on" + # Checkboxes: "on" if checked, absent if unchecked. The admin's toggles + # are rendered disabled (always included), and a disabled checkbox is + # never submitted, so it would read as "off" here. + include_ondeck = user.get("is_admin") or form_data.get(f"include_ondeck_{title}") == "on" + include_watchlist = user.get("is_admin") or form_data.get(f"include_watchlist_{title}") == "on" # skip = NOT include (checkbox off means skip) user["skip_ondeck"] = not include_ondeck @@ -425,6 +432,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 _page_alert(level: str, message: str) -> HTMLResponse: + """Show a 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 +518,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 _page_alert("error", "Failed to add mapping") @router.put("/paths/{index}", response_class=HTMLResponse) @@ -494,11 +534,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 _page_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 +575,30 @@ def update_path_mapping( } ) else: - return HTMLResponse("
Failed to update mapping
") + return _page_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 _page_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 _page_alert("error", "Failed to delete mapping") # ============================================================================= @@ -573,15 +623,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 +808,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 +832,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) @@ -947,6 +997,25 @@ def save_cache_settings(request: Request, form_data: ImmutableMultiDict = Depend excluded_folders = form_data.getlist("excluded_folders") settings_dict["excluded_folders"] = [f for f in excluded_folders if f and f.strip()] + # The engine reads an unparseable size as "no limit", so refuse it here + # rather than save a cap that silently does nothing. + size_fields = (("cache_drive_size", "Cache Drive Size", False), + ("cache_limit", "Cache Limit", True), + ("min_free_space", "Min Free Space", True), + ("plexcache_quota", "PlexCache Quota", True)) + problems = [] + for key, label, allow_percent in size_fields: + if key in settings_dict: + error = size_setting_error(str(settings_dict[key]), allow_percent=allow_percent) + if error: + problems.append(f"{label}: {error}") + if problems: + return templates.TemplateResponse( + request, + "partials/alert.html", + {"type": "error", "message": "Not saved. " + " ".join(problems)} + ) + success = settings_service.save_cache_settings(settings_dict) if success: @@ -1383,15 +1452,14 @@ def add_arr_instance( success = settings_service.add_arr_instance(instance) if success: - instances = settings_service.get_arr_instances() - index = len(instances) - 1 + # Re-render the whole list so the "none configured" placeholder goes return templates.TemplateResponse( request, - "settings/partials/arr_instance_card.html", - {"instance": instances[index], "index": index} + "settings/partials/arr_instances_list.html", + {"instances": settings_service.get_arr_instances()} ) else: - return HTMLResponse("
Failed to add instance
") + return _page_alert("error", "Failed to add instance") @router.put("/integrations/instances/{index}", response_class=HTMLResponse) @@ -1694,13 +1762,16 @@ def import_settings_file(request: Request, form_data: ImmutableMultiDict = Depen result = settings_service.import_settings(settings_data, merge=merge_mode) if result["success"]: - mode_text = "merged with" if merge_mode else "replaced" + mode_text = "merged" if merge_mode else "replaced" return templates.TemplateResponse( request, "partials/alert.html", { "type": "success", - "message": f"Settings {mode_text} successfully. Refresh the page to see changes." + "message": (f"Settings {mode_text} successfully. " + + ("It was exported without sensitive data, so this installation's Plex connection, " + "login, keys and users were kept. " if result.get("redacted") else "") + + "Refresh the page to see changes.") } ) else: diff --git a/web/services/settings_service.py b/web/services/settings_service.py index fa828e2..8932f22 100644 --- a/web/services/settings_service.py +++ b/web/services/settings_service.py @@ -13,6 +13,7 @@ # Defaults come from core's dataclass rather than repeated literals: the engine # reads CacheConfig, so anything hardcoded here can disagree with what runs. from core.config import CacheConfig +from core.system_utils import size_setting_error from web.dependencies import get_system_detector logger = logging.getLogger(__name__) @@ -20,6 +21,93 @@ # File cache for Plex data (web UI) - use DATA_DIR for Docker compatibility WEB_PLEX_CACHE_FILE = DATA_DIR / "web_plex_cache.json" +# Top-level keys a settings import recognises; anything else is reported as +# "unknown" (and kept). tests/test_settings_import_validation.py checks every +# key the engine reads and the app writes is listed here. +KNOWN_SETTINGS_KEYS = frozenset({ + # Plex connection and libraries + "PLEX_URL", "PLEX_TOKEN", "valid_sections", "path_mappings", "plexcache_client_id", + "cache_dir", "plex_db_path", + # Users + "users", "users_toggle", "skip_ondeck", "skip_watchlist", + "remote_watchlist_toggle", "remote_watchlist_rss_url", "auth_link_enabled", + # Cache behaviour + "watchlist_toggle", "watchlist_episodes", "watchlist_retention_days", "ondeck_retention_days", + "watched_move", "prefetch_minimum_minutes", "create_plexcached_backups", "cleanup_empty_folders", + "hardlinked_files", "check_hardlinks_on_restore", "use_symlinks", "cache_retention_hours", + "cache_associated_files", "auto_transfer_upgrades", "backup_upgraded_files", + "days_to_monitor", "number_episodes", "excluded_folders", + "recently_added_days", "recently_added_max_items", + # Cache limits and eviction + "cache_drive_size", "cache_limit", "min_free_space", "plexcache_quota", + "cache_eviction_mode", "cache_eviction_threshold_percent", "eviction_min_priority", + # Pinned media + "pinned_preferred_resolution", "pinned_media", + # Notifications and logging + "notification_type", "unraid_level", "unraid_levels", "webhook_url", + "webhook_level", "webhook_levels", "max_log_files", "keep_error_logs_days", + "activity_retention_hours", "time_format", + # Schedule + "schedule", + # Security and API access + "auth_enabled", "auth_session_hours", "auth_admin_plex_id", "auth_admin_username", + "auth_password_enabled", "auth_password_username", "auth_password_hash", "auth_password_salt", + "api_key", "api_run_cooldown_seconds", + # Advanced settings + "max_concurrent_moves_array", "max_concurrent_moves_cache", "exit_if_active_session", + # Integrations (Sonarr/Radarr) - multi-instance list + "arr_instances", + # Legacy keys (migrated or ignored on load) + "sonarr_enabled", "sonarr_url", "sonarr_api_key", + "radarr_enabled", "radarr_url", "radarr_api_key", + "plex_source", "real_source", "nas_library_folders", "plex_library_folders", + "skip_users", "skip", "firststart", "debug", + # Set by export_settings(include_sensitive=False) + "_redacted_export", +}) + +REDACTED = "[REDACTED]" +REDACTED_EXPORT_MARKER = "_redacted_export" + +# What export_settings(include_sensitive=False) blanks, masks or anonymises. +# Importing such a file keeps the current installation's values for these. +REDACTED_EXPORT_KEYS = ( + "PLEX_URL", "PLEX_TOKEN", "plexcache_client_id", "remote_watchlist_rss_url", "webhook_url", + "auth_password_hash", "auth_password_salt", "auth_password_username", + "auth_admin_plex_id", "auth_admin_username", "api_key", "users", "_cached_users", +) + + +def is_redacted_export(settings_data: Dict[str, Any]) -> bool: + """True for a file exported without sensitive data. Older exports have no + marker, so the placeholder text identifies them.""" + if settings_data.get(REDACTED_EXPORT_MARKER): + return True + return REDACTED in json.dumps({k: v for k, v in settings_data.items() if k != "pinned_media"}) + + +def _keep_current_secrets(imported: Dict[str, Any], current: Dict[str, Any]) -> Dict[str, Any]: + """Merge a redacted export onto the current settings without writing its + placeholders: "[REDACTED]" URLs, blank tokens and anonymised users would + otherwise replace the real values and break the Plex connection and login.""" + import copy + result = {k: v for k, v in imported.items() if k != REDACTED_EXPORT_MARKER} + for key in REDACTED_EXPORT_KEYS: + if key in current: + result[key] = copy.deepcopy(current[key]) + else: + result.pop(key, None) + # Sonarr/Radarr keys were blanked; take each one from the matching current instance + current_keys = {(i.get("type"), i.get("url")): i.get("api_key", "") + for i in current.get("arr_instances", []) if isinstance(i, dict)} + if isinstance(result.get("arr_instances"), list): + result["arr_instances"] = [ + dict(i, api_key=i.get("api_key") or current_keys.get((i.get("type"), i.get("url")), "")) + if isinstance(i, dict) else i + for i in result["arr_instances"] + ] + return result + @dataclass class PathMapping: @@ -1528,6 +1616,9 @@ def export_settings(self, include_sensitive: bool = True) -> Dict[str, Any]: for inst in settings.get("arr_instances", []): inst["api_key"] = "" + # The API key starts cache runs; an import without one simply has no key + settings.pop("api_key", None) + # Redact auth credentials and identity if "auth_password_hash" in settings: settings["auth_password_hash"] = "[REDACTED]" @@ -1550,6 +1641,8 @@ def export_settings(self, include_sensitive: bool = True) -> Dict[str, Any]: for i, user in enumerate(settings.get("users", []), 1): if "title" in user: user["title"] = f"User {i}" + if "username" in user: + user["username"] = f"User_{i}" if "token" in user: user["token"] = "" if "id" in user: @@ -1557,6 +1650,9 @@ def export_settings(self, include_sensitive: bool = True) -> Dict[str, Any]: if "uuid" in user: user["uuid"] = "[REDACTED]" + # Lets an import tell this apart from a backup (see _keep_current_secrets) + settings[REDACTED_EXPORT_MARKER] = True + return settings def validate_import_settings(self, settings_data: Dict[str, Any]) -> Dict[str, Any]: @@ -1581,10 +1677,16 @@ def validate_import_settings(self, settings_data: Dict[str, Any]) -> Dict[str, A errors.append("Settings file is empty") return {"valid": False, "errors": errors, "warnings": warnings} - # Check for required Plex settings (warn if missing) - if not settings_data.get("PLEX_URL"): + redacted = is_redacted_export(settings_data) + if redacted: + warnings.append("This file was exported without sensitive data. Importing it keeps this " + "installation's Plex connection, login, API key, users and Sonarr/Radarr keys.") + + # Check for required Plex settings (warn if missing). A redacted file + # never has them; the import keeps the current ones instead. + if not redacted and not settings_data.get("PLEX_URL"): warnings.append("Missing PLEX_URL - will need to configure Plex connection") - if not settings_data.get("PLEX_TOKEN"): + if not redacted and not settings_data.get("PLEX_TOKEN"): warnings.append("Missing PLEX_TOKEN - will need to re-authenticate with Plex") # Check path mappings structure @@ -1600,7 +1702,7 @@ def validate_import_settings(self, settings_data: Dict[str, Any]) -> Dict[str, A warnings.append(f"Path mapping '{mapping.get('name', i + 1)}' missing plex_path or real_path") # Check users structure - users = settings_data.get("users", []) + users = [] if redacted else settings_data.get("users", []) if users: if not isinstance(users, list): errors.append("users must be a list") @@ -1609,34 +1711,15 @@ def validate_import_settings(self, settings_data: Dict[str, Any]) -> Dict[str, A if users_without_tokens > 0: warnings.append(f"{users_without_tokens} user(s) missing tokens - will need to sync users") - # Check cache limit format - cache_limit = settings_data.get("cache_limit", "") - if cache_limit and not any(cache_limit.upper().endswith(suffix) for suffix in ["GB", "TB", "MB"]): - warnings.append(f"cache_limit '{cache_limit}' may not be a valid format (expected e.g., '250GB')") + # Size settings the engine would read as "no limit" + for key, allow_percent in (("cache_drive_size", False), ("cache_limit", True), + ("min_free_space", True), ("plexcache_quota", True)): + problem = size_setting_error(str(settings_data.get(key) or ""), allow_percent=allow_percent) + if problem: + warnings.append(f"{key}: {problem}") # Check for unknown top-level keys (informational) - known_keys = { - "PLEX_URL", "PLEX_TOKEN", "valid_sections", "path_mappings", "users", - "users_toggle", "skip_ondeck", "skip_watchlist", "watchlist_toggle", - "watchlist_episodes", "watchlist_retention_days", "watched_move", "prefetch_minimum_minutes", - "create_plexcached_backups", "hardlinked_files", "check_hardlinks_on_restore", "use_symlinks", "cache_retention_hours", - "cache_limit", "min_free_space", "plexcache_quota", "cache_eviction_mode", "cache_eviction_threshold_percent", - "eviction_min_priority", "remote_watchlist_toggle", "remote_watchlist_rss_url", - "notification_type", "unraid_level", "unraid_levels", "webhook_url", - "webhook_level", "webhook_levels", "max_log_files", "keep_error_logs_days", - "days_to_monitor", "number_episodes", "activity_retention_hours", - "excluded_folders", "pinned_preferred_resolution", "pinned_media", - # Advanced settings - "max_concurrent_moves_array", "max_concurrent_moves_cache", "exit_if_active_session", - # Integrations (Sonarr/Radarr) - multi-instance list - "arr_instances", - # Legacy flat keys (auto-migrated to arr_instances) - "sonarr_enabled", "sonarr_url", "sonarr_api_key", - "radarr_enabled", "radarr_url", "radarr_api_key", - # Legacy keys that may exist - "plex_source", "real_source", "nas_library_folders", "plex_library_folders" - } - unknown_keys = set(settings_data.keys()) - known_keys + unknown_keys = set(settings_data.keys()) - KNOWN_SETTINGS_KEYS if unknown_keys: warnings.append(f"Unknown settings will be preserved: {', '.join(sorted(unknown_keys)[:5])}") @@ -1662,6 +1745,9 @@ def import_settings(self, settings_data: Dict[str, Any], merge: bool = False) -> # pins live in a separate tracker JSON, not inside plexcache_settings. settings_data = dict(settings_data) # don't mutate caller's dict pinned_payload = settings_data.pop("pinned_media", None) + redacted = is_redacted_export(settings_data) + if redacted: + settings_data = _keep_current_secrets(settings_data, self._load_raw()) if merge: # Load existing and merge @@ -1695,7 +1781,11 @@ def import_settings(self, settings_data: Dict[str, Any], merge: bool = False) -> logging.warning(f"import_settings: pinned tracker restore failed: {e}") # Invalidate caches since settings changed self.invalidate_plex_cache() - return {"success": True, "message": "Settings imported successfully"} + message = "Settings imported successfully" + if redacted: + message += (" (exported without sensitive data, so this installation's Plex " + "connection, login, keys and users were kept)") + return {"success": True, "message": message, "redacted": redacted} else: return {"success": False, "message": "Failed to save settings file"} diff --git a/web/static/css/plex-theme.css b/web/static/css/plex-theme.css index 3cb4711..605dfbb 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 { @@ -1682,6 +1687,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 8f0d607..551a717 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -137,3 +137,127 @@ 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); + }); + }); +})(); + +// 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/macros/pin_button.html b/web/templates/macros/pin_button.html index 427dbdf..253719e 100644 --- a/web/templates/macros/pin_button.html +++ b/web/templates/macros/pin_button.html @@ -36,7 +36,7 @@
Export Settings
Only include sensitive data if you're backing up for yourself. Uncheck to share settings safely. - When unchecked, redacts: tokens, Plex URL, RSS URL, webhook URL, client ID, usernames, and user IDs. + When unchecked, redacts: tokens, Plex URL, RSS URL, webhook URL, client ID, usernames, user IDs, login details and the API key. + Importing a redacted file keeps the existing values for these.
@@ -343,9 +344,11 @@

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/integrations.html b/web/templates/settings/integrations.html index 43a95f6..9799584 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/libraries.html b/web/templates/settings/libraries.html index c7d4784..39f064d 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/arr_instance_card.html b/web/templates/settings/partials/arr_instance_card.html index a528c59..b633454 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/custom_mappings.html b/web/templates/settings/partials/custom_mappings.html new file mode 100644 index 0000000..ea29a37 --- /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 9c5652c..1111e38 100644 --- a/web/templates/settings/partials/path_mapping_card.html +++ b/web/templates/settings/partials/path_mapping_card.html @@ -17,9 +17,10 @@ @@ -56,6 +57,9 @@
+ {# Lets the server refuse the save if the list changed since this card rendered #} + +
Editing: {{ mapping.name }}