From 1196952a94926bd0f92d98dbb0d3419ead1ac87b Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:43:37 -0500 Subject: [PATCH 1/2] Settings: allow removing a secondary library path mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Libraries with more than one path mapping (e.g. Movies + Movies UHD) had no way to delete the extra mapping from the UI — Edit Paths could only edit fields. Add a per-mapping Remove button (shown when a library has 2+ mappings) that marks the mapping for deletion via a hidden delete_ flag; PUT /libraries/{id}/paths now drops flagged mappings instead of updating them. The last remaining mapping keeps no Remove button (toggle the library off instead). --- tests/test_library_paths_delete.py | 131 ++++++++++++++++++ web/routers/settings.py | 13 +- web/templates/settings/libraries.html | 11 ++ .../settings/partials/library_card.html | 13 +- 4 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 tests/test_library_paths_delete.py diff --git a/tests/test_library_paths_delete.py b/tests/test_library_paths_delete.py new file mode 100644 index 00000000..1cc40e63 --- /dev/null +++ b/tests/test_library_paths_delete.py @@ -0,0 +1,131 @@ +"""Route test: deleting a secondary path mapping from a library. + +A library can have multiple path mappings (e.g. Movies + Movies UHD). The Edit +Paths form lets the user mark a mapping for removal (hidden ``delete_`` flag); +``PUT /settings/libraries/{section_id}/paths`` must drop those and keep the rest. + +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 json +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 _two_mapping_settings(): + return { + "PLEX_URL": "http://localhost:32400", + "PLEX_TOKEN": "abc", + "valid_sections": [4], + "path_mappings": [ + {"name": "Movies", "plex_path": "/data/Movies/", + "real_path": "/mnt/user0/Movies/", "cache_path": "/mnt/cache/Movies/", + "cacheable": True, "enabled": True, "section_id": 4}, + {"name": "Movies UHD", "plex_path": "/nas/Movies UHD/", + "real_path": "/mnt/remotes/NAS_Media/Movies UHD/", + "cache_path": "/mnt/cache/Movies UHD/", + "cacheable": False, "enabled": True, "section_id": 4}, + ], + "cache_dir": "/mnt/cache", + } + + +@pytest.fixture +def service(tmp_path): + settings_file = tmp_path / "plexcache_settings.json" + settings_file.write_text(json.dumps(_two_mapping_settings(), 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 + svc = SettingsService() + svc._cached_settings = None + with patch.object(svc, "get_plex_libraries", return_value=[_MOVIES_LIB]): + yield svc + + +@pytest.fixture +def client(service): + from web.routers import settings as settings_router + app = FastAPI() + app.include_router(settings_router.router, prefix="/settings") + with patch("web.routers.settings.get_settings_service", return_value=service): + yield TestClient(app), service + + +def _form(delete_uhd=False): + form = { + "name_0": "Movies", "plex_path_0": "/data/Movies/", + "real_path_0": "/mnt/user0/Movies/", "cache_path_0": "/mnt/cache/Movies/", + "host_cache_path_0": "", "cacheable_0": "on", + "name_1": "Movies UHD", "plex_path_1": "/nas/Movies UHD/", + "real_path_1": "/mnt/remotes/NAS_Media/Movies UHD/", + "cache_path_1": "/mnt/cache/Movies UHD/", "host_cache_path_1": "", + } + if delete_uhd: + form["delete_1"] = "1" + return form + + +def test_delete_secondary_mapping_removes_it(client): + test_client, service = client + r = test_client.put("/settings/libraries/4/paths", data=_form(delete_uhd=True)) + assert r.status_code == 200 + + raw = service._load_raw() + names = [m["name"] for m in raw["path_mappings"]] + assert names == ["Movies"] # Movies UHD removed + assert raw["valid_sections"] == [4] # library still valid (primary remains) + # The refreshed card no longer mentions the removed mapping. + assert "Movies UHD" not in r.text + + +def test_no_delete_flag_keeps_both(client): + test_client, service = client + r = test_client.put("/settings/libraries/4/paths", data=_form(delete_uhd=False)) + assert r.status_code == 200 + + raw = service._load_raw() + names = {m["name"] for m in raw["path_mappings"]} + assert names == {"Movies", "Movies UHD"} + + +def test_remove_button_shown_only_with_multiple_mappings(client): + test_client, service = client + # Two mappings → editing exposes a Remove control. + r = test_client.get("/settings/libraries") + # The libraries page lists cards; ensure the edit form carries delete plumbing + # when a library has more than one mapping. + assert "removeLibraryMapping(4" in r.text or "delete_1" in r.text diff --git a/web/routers/settings.py b/web/routers/settings.py index 16c21c30..66dfeddc 100644 --- a/web/routers/settings.py +++ b/web/routers/settings.py @@ -706,8 +706,15 @@ async def update_library_paths(request: Request, section_id: int): if m.get("section_id") == section_id ] - # Parse form fields — each mapping's fields are suffixed with its position + # Parse form fields — each mapping's fields are suffixed with its position. + # A mapping flagged delete_=1 is dropped (removed from the list) instead + # of updated, so secondary mappings can be deleted from the UI. + indices_to_delete = [] for pos, idx in enumerate(lib_indices): + if form.get(f"delete_{pos}") == "1": + indices_to_delete.append(idx) + continue + name = form.get(f"name_{pos}", "") plex_path = form.get(f"plex_path_{pos}", "") real_path = form.get(f"real_path_{pos}", "") @@ -730,6 +737,10 @@ async def update_library_paths(request: Request, section_id: int): # Clear auto_fill flag — user has reviewed paths }) + # Remove deleted mappings (highest index first so earlier indices stay valid). + for idx in sorted(indices_to_delete, reverse=True): + del all_mappings[idx] + raw["path_mappings"] = all_mappings settings_service._rebuild_valid_sections(raw) settings_service._save_raw(raw) diff --git a/web/templates/settings/libraries.html b/web/templates/settings/libraries.html index 4d46aa4a..a7a747f4 100644 --- a/web/templates/settings/libraries.html +++ b/web/templates/settings/libraries.html @@ -138,6 +138,17 @@