diff --git a/app/api/files.py b/app/api/files.py index 4b9a382..79b6c4e 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -28,3 +28,20 @@ def discover_files( ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/folders") +def discover_folders( + root_id: str = Query(..., min_length=1), + subpath: str = Query(""), + limit: int = Query(500, ge=1, le=2000), +): + service = FileDiscoveryService() + try: + return service.list_folders( + root_id=root_id, + subpath=subpath, + limit=limit, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) diff --git a/app/services/file_discovery_service.py b/app/services/file_discovery_service.py index 9cd0323..646938b 100644 --- a/app/services/file_discovery_service.py +++ b/app/services/file_discovery_service.py @@ -83,6 +83,53 @@ class FileDiscoveryService: "items": files, } + def list_folders( + self, + root_id: str, + subpath: str = "", + limit: int = 500, + ) -> dict: + root = self._get_root_by_id(root_id) + target = self._resolve_target(root["path"], subpath) + + folders = [] + if not target.exists(): + return { + "root_id": root["id"], + "root_path": str(root["path"]), + "subpath": subpath, + "limit": limit, + "items": folders, + } + if not target.is_dir(): + raise ValueError("resolved target is not a directory") + + for entry in target.iterdir(): + if len(folders) >= limit: + break + if not entry.is_dir(): + continue + try: + relative_to_root = entry.resolve().relative_to(root["path"]) + except ValueError: + continue + folders.append( + { + "name": entry.name, + "subpath": str(relative_to_root), + "path": str(entry), + } + ) + + folders.sort(key=lambda x: x["name"].lower()) + return { + "root_id": root["id"], + "root_path": str(root["path"]), + "subpath": subpath, + "limit": limit, + "items": folders, + } + def _load_allowed_extensions(self) -> set[str]: raw = os.getenv("ALLOWED_EXTENSIONS", "").strip() if raw: diff --git a/app/services/session_service.py b/app/services/session_service.py index ec6ff45..6fe3416 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -1,5 +1,6 @@ import json import os +import re import sqlite3 import time from datetime import datetime, timezone @@ -410,6 +411,7 @@ class SessionService: or "Unknown Series" ) year = episode.get("year") or "0000" + series = self._normalize_series_name(series, year) title = episode.get("title") or "Untitled" season_raw = episode.get("season_number") or episode.get("season") or 0 @@ -449,6 +451,16 @@ class SessionService: "items": previews, } + def _normalize_series_name(self, series: str, year: int | str) -> str: + text = str(series or "").strip() + year_str = str(year or "").strip() + if not text or not year_str: + return text + + # Strip trailing " (YEAR)" to avoid duplicate year in the template output. + pattern = re.compile(rf"\s*\({re.escape(year_str)}\)\s*$") + return pattern.sub("", text).strip() + def execute_rename(self, session_id: str, confirm: bool) -> dict: if not confirm: raise ValueError("confirm=true is required to execute rename") diff --git a/app/static/app.js b/app/static/app.js index 96f3601..69ff796 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -6,7 +6,9 @@ selectedSeries: null, episodes: [], roots: [], + folders: [], discoveredFiles: [], + currentSubpath: "", }; const el = { @@ -22,10 +24,12 @@ clearSelectedEpisodesBtn: document.getElementById("clearSelectedEpisodesBtn"), selectedEpisodesList: document.getElementById("selectedEpisodesList"), rootsSelect: document.getElementById("rootsSelect"), + foldersSelect: document.getElementById("foldersSelect"), refreshRootsBtn: document.getElementById("refreshRootsBtn"), subpathInput: document.getElementById("subpathInput"), + loadFoldersBtn: document.getElementById("loadFoldersBtn"), + loadFilesBtn: document.getElementById("loadFilesBtn"), recursiveInput: document.getElementById("recursiveInput"), - discoverBtn: document.getElementById("discoverBtn"), discoveredFilesList: document.getElementById("discoveredFilesList"), refreshSelectedFilesBtn: document.getElementById("refreshSelectedFilesBtn"), clearSelectedFilesBtn: document.getElementById("clearSelectedFilesBtn"), @@ -89,6 +93,11 @@ opt.textContent = `${root.id}: ${root.path}`; el.rootsSelect.appendChild(opt); } + if (state.roots.length > 0) { + state.currentSubpath = ""; + el.subpathInput.value = ""; + await loadFolders(); + } out("Roots loaded", data); } @@ -192,10 +201,40 @@ return data; } + async function loadFolders() { + const rootId = el.rootsSelect.value; + if (!rootId) throw new Error("No root selected"); + const subpath = (el.subpathInput.value || "").trim(); + state.currentSubpath = subpath; + const data = await api( + `/api/files/folders?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(subpath)}&limit=500` + ); + state.folders = data.items || []; + el.foldersSelect.innerHTML = ""; + + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = state.folders.length ? "Choose folder..." : "(No folders)"; + el.foldersSelect.appendChild(placeholder); + + state.folders.forEach((folder) => { + const opt = document.createElement("option"); + opt.value = folder.subpath; + opt.textContent = folder.subpath || folder.name; + el.foldersSelect.appendChild(opt); + }); + + out("Folders loaded", data); + } + async function discoverFiles() { const rootId = el.rootsSelect.value; if (!rootId) throw new Error("No root selected"); - const subpath = encodeURIComponent((el.subpathInput.value || "").trim()); + const selectedFolder = (el.foldersSelect.value || "").trim(); + if (!selectedFolder) throw new Error("Choose a folder first"); + el.subpathInput.value = selectedFolder; + state.currentSubpath = selectedFolder; + const subpath = encodeURIComponent(selectedFolder); const recursive = el.recursiveInput.checked ? "true" : "false"; const data = await api(`/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${subpath}&recursive=${recursive}&limit=200`); state.discoveredFiles = data.items || []; @@ -269,8 +308,28 @@ const data = await api(q("/api/session/rename-execute") + "&confirm=true", { method: "POST", }); - out("Rename execute", data); - await loadSelectedFiles(); + if (data.executed) { + out("Rename execute: success", data); + // Keep UI in sync with renamed destinations using existing selected-files endpoints. + const renamedFiles = (data.items || []) + .filter((item) => item.status === "renamed") + .map((item) => ({ + path: item.destination_path, + name: item.proposed_filename, + })); + await api(q("/api/session/selected-files"), { method: "DELETE" }); + if (renamedFiles.length > 0) { + await api(q("/api/session/selected-files"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: renamedFiles }), + }); + } + await loadSelectedFiles(); + return; + } + + out("Rename execute: preflight failed", data); } async function withHandler(fn, btn) { @@ -296,7 +355,20 @@ }, el.clearSelectedEpisodesBtn) ); el.refreshRootsBtn.addEventListener("click", () => withHandler(loadRoots, el.refreshRootsBtn)); - el.discoverBtn.addEventListener("click", () => withHandler(discoverFiles, el.discoverBtn)); + el.rootsSelect.addEventListener("change", () => withHandler(async () => { + state.currentSubpath = ""; + el.subpathInput.value = ""; + await loadFolders(); + el.discoveredFilesList.innerHTML = ""; + }, el.rootsSelect)); + el.loadFoldersBtn.addEventListener("click", () => withHandler(loadFolders, el.loadFoldersBtn)); + el.loadFilesBtn.addEventListener("click", () => withHandler(discoverFiles, el.loadFilesBtn)); + el.foldersSelect.addEventListener("change", () => { + const chosen = (el.foldersSelect.value || "").trim(); + if (chosen) { + el.subpathInput.value = chosen; + } + }); el.refreshSelectedFilesBtn.addEventListener("click", () => withHandler(loadSelectedFiles, el.refreshSelectedFilesBtn)); el.clearSelectedFilesBtn.addEventListener("click", () => withHandler(async () => { @@ -312,6 +384,7 @@ async function init() { el.sessionMeta.textContent = `session_id: ${state.sessionId}`; + el.recursiveInput.checked = true; bindEvents(); await loadRoots(); await loadSelectedEpisodes(); diff --git a/app/static/styles.css b/app/static/styles.css index 704c82e..36b3ac1 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -30,9 +30,10 @@ body { .grid { display: grid; - grid-template-columns: repeat(2, minmax(280px, 1fr)); + grid-template-columns: repeat(4, minmax(280px, 1fr)); gap: 12px; padding: 12px; + align-items: start; } .panel { @@ -131,6 +132,12 @@ button.secondary { font-size: 12px; } +@media (max-width: 1600px) { + .grid { + grid-template-columns: repeat(2, minmax(280px, 1fr)); + } +} + @media (max-width: 900px) { .grid { grid-template-columns: 1fr; diff --git a/app/templates/index.html b/app/templates/index.html index 565511b..f28eb13 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -14,7 +14,7 @@
-

Panel 1: TVDB Search

+

1. TV Shows

@@ -24,7 +24,7 @@
-

Panel 2: Episodes

+

2. Episodes

@@ -32,7 +32,7 @@
-

Panel 3: Selected Episodes

+

3. Selected Episodes

@@ -41,20 +41,23 @@
-

Panel 4: Selected Files + File Discovery

+

4. Selected Files

+ +
- + + + -

Discovered Files

diff --git a/data/session_state.sqlite3 b/data/session_state.sqlite3 index 7b4364c..1f1f953 100644 Binary files a/data/session_state.sqlite3 and b/data/session_state.sqlite3 differ diff --git a/feature_tests_filename_preview.sh b/feature_tests_filename_preview.sh index 0e11d3d..967b887 100755 --- a/feature_tests_filename_preview.sh +++ b/feature_tests_filename_preview.sh @@ -29,8 +29,8 @@ cat > "${TMP_DIR}/episodes_payload.json" <<'JSON' "items": [ { "id": 9784113, - "series": "Elsbeth", - "year": "2024", + "series": "All's Fair (2025)", + "year": "2025", "season_number": 1, "episode_number": 1, "title": "Pilot" @@ -89,7 +89,7 @@ data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) assert isinstance(data, dict), "response must be an object" assert isinstance(data.get("items"), list), "items must be a list" assert len(data["items"]) == 2, "expected 2 preview items" -assert data["items"][0]["proposed_filename"] == "Elsbeth (2024) - S01E01 - Pilot.mkv", "first proposed filename mismatch" +assert data["items"][0]["proposed_filename"] == "All's Fair (2025) - S01E01 - Pilot.mkv", "first proposed filename mismatch" assert data["items"][1]["proposed_filename"] == "Elsbeth (2024) - S01E02 - A Classic New York Character.mp4", "second proposed filename mismatch" print("filename preview validation passed") PY diff --git a/feature_tests_ui.sh b/feature_tests_ui.sh index e3d480e..c1e5bc3 100755 --- a/feature_tests_ui.sh +++ b/feature_tests_ui.sh @@ -26,10 +26,10 @@ from pathlib import Path html = Path(sys.argv[1]).read_text(encoding="utf-8") required = [ - "Panel 1: TVDB Search", - "Panel 2: Episodes", - "Panel 3: Selected Episodes", - "Panel 4: Selected Files + File Discovery", + "1. TV Shows", + "2. Episodes", + "3. Selected Episodes", + "4. Selected Files", "Mapping Preview", "Filename Preview", "Rename Execute (confirm=true)", @@ -58,6 +58,7 @@ assert ".grid" in css, "styles.css missing expected grid styles" assert "session_id" in js, "app.js missing session_id usage" assert "/api/tvdb/search" in js, "app.js missing search endpoint usage" assert "/api/files/discover" in js, "app.js missing discovery endpoint usage" +assert "/api/files/folders" in js, "app.js missing folders endpoint usage" print("static assets validation passed") PY @@ -74,6 +75,7 @@ required_endpoints = [ "/api/session/selected-episodes", "/api/session/selected-files", "/api/files/roots", + "/api/files/folders", "/api/files/discover", "/api/session/mapping-preview", "/api/session/filename-preview",