Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions tests/test_library_paths_delete.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""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_<pos>`` 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
# Removal uses the styled modal, not the native browser confirm().
assert 'id="remove-mapping-modal"' in r.text
assert "confirmRemoveMapping()" in r.text
13 changes: 12 additions & 1 deletion web/routers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<pos>=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}", "")
Expand All @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions web/templates/settings/libraries.html
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,34 @@ <h3 style="font-size: 1rem; color: var(--plex-text-muted); margin-bottom: 1rem;"
</div>
</div>

<!-- Remove path-mapping confirmation (styled modal — replaces native confirm()) -->
<div id="remove-mapping-modal" class="modal" style="display: none;">
<div class="modal-backdrop" onclick="closeRemoveMappingModal()"></div>
<div class="modal-content" style="max-width: 440px;">
<div class="modal-header">
<h3>Remove path mapping</h3>
<button type="button" class="modal-close" onclick="closeRemoveMappingModal()" aria-label="Close">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<p style="margin: 0 0 0.75rem 0;">
Remove the <strong id="remove-mapping-name">this mapping</strong> path mapping?
</p>
<p style="margin: 0; color: var(--plex-text-muted); font-size: 0.85rem;">
It's removed from this library when you click <strong>Save</strong>. Cached files are not touched.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeRemoveMappingModal()">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmRemoveMapping()">
<i data-lucide="trash-2" style="width: 14px; height: 14px;"></i>
Remove
</button>
</div>
</div>
</div>

<script src="/static/js/path-browser.js"></script>
<script>
function validatePathInput(input) {
Expand Down Expand Up @@ -138,6 +166,39 @@ <h3 style="font-size: 1rem; color: var(--plex-text-muted); margin-bottom: 1rem;"
}
lucide.createIcons();
}

// Mark a library path mapping for deletion (styled modal — replaces native
// confirm()). The hidden delete_<pos> flag is submitted with the form; the
// server drops the mapping on Save. The block is hidden (but its inputs still
// submit) so the position alignment holds.
var _pendingMappingRemoval = null;
function removeLibraryMapping(sectionId, pos, name) {
_pendingMappingRemoval = { sectionId: sectionId, pos: pos };
document.getElementById('remove-mapping-name').textContent = name || 'this mapping';
var modal = document.getElementById('remove-mapping-modal');
if (modal) {
modal.style.display = 'flex';
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
function closeRemoveMappingModal() {
var modal = document.getElementById('remove-mapping-modal');
if (modal) modal.style.display = 'none';
_pendingMappingRemoval = null;
}
function confirmRemoveMapping() {
var p = _pendingMappingRemoval;
closeRemoveMappingModal();
if (!p) return;
var flag = document.getElementById('lib-del-' + p.sectionId + '-' + p.pos);
if (flag) flag.value = '1';
var block = document.getElementById('lib-edit-map-' + p.sectionId + '-' + p.pos);
if (block) block.style.display = 'none';
}
// Esc closes the removal modal.
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && _pendingMappingRemoval) closeRemoveMappingModal();
});
// toggleEditMode(index) is defined globally in /static/js/app.js
</script>
{% endblock %}
13 changes: 12 additions & 1 deletion web/templates/settings/partials/library_card.html
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,18 @@
hx-swap="outerHTML">
{% for mapping in card.mappings %}
{% set unrecognized = mapping.auto_fill_recognized is defined and not mapping.auto_fill_recognized %}
<div style="padding: 0.75rem; background: var(--plex-bg-darker); border: 1px solid {% if unrecognized %}var(--plex-warning, #e67e22){% else %}var(--plex-orange){% endif %}; border-radius: var(--plex-radius-sm); margin-bottom: 0.5rem;">
<div id="lib-edit-map-{{ card.library.id }}-{{ loop.index0 }}"
style="padding: 0.75rem; background: var(--plex-bg-darker); border: 1px solid {% if unrecognized %}var(--plex-warning, #e67e22){% else %}var(--plex-orange){% endif %}; border-radius: var(--plex-radius-sm); margin-bottom: 0.5rem;">
<input type="hidden" name="delete_{{ loop.index0 }}" value="" id="lib-del-{{ card.library.id }}-{{ loop.index0 }}">
{% if card.mappings|length > 1 %}
<div class="flex items-center justify-between mb-1">
<strong style="font-size: 0.9rem;">{{ mapping.name }}</strong>
<button type="button" class="btn btn-sm btn-danger"
onclick="removeLibraryMapping({{ card.library.id }}, {{ loop.index0 }}, '{{ mapping.name|replace("'", "") }}')">
<i data-lucide="trash-2" style="width: 13px; height: 13px;"></i> Remove
</button>
</div>
{% endif %}
{% if unrecognized %}
<div style="margin-bottom: 0.5rem; padding: 0.35rem 0.5rem; background: rgba(230, 126, 34, 0.1); border-radius: var(--plex-radius-sm); font-size: 0.8rem; color: var(--plex-warning, #e67e22);">
<i data-lucide="alert-triangle" style="width: 13px; height: 13px; vertical-align: middle;"></i>
Expand Down
Loading