From 06c144d2fc5d2b8f7cfa5c6a1ca55606233a96f4 Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 8 Mar 2026 07:41:24 +0100 Subject: [PATCH] feat & Bugfix: layout en rename (year) --- app/api/files.py | 17 +++++ app/services/file_discovery_service.py | 47 ++++++++++++++ app/services/session_service.py | 12 ++++ app/static/app.js | 83 +++++++++++++++++++++++-- app/static/styles.css | 9 ++- app/templates/index.html | 17 ++--- data/session_state.sqlite3 | Bin 65536 -> 65536 bytes feature_tests_filename_preview.sh | 6 +- feature_tests_ui.sh | 10 +-- 9 files changed, 181 insertions(+), 20 deletions(-) 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 7b4364cdbd453e7cae1ee7d17bfd91abadac8432..1f1f95379a03c281f4817b2aa15fa0515aa5f88f 100644 GIT binary patch delta 2705 zcmdT`Z%i9y7{BYicibIquc)ZNVApE_2`hcCExneyFhX!s!O7G_#Q{e-wubU&y#g__ zDTj-?jKnqX$Ejn^ElUy7yIRU zPoL-cJTB%wm5itC+WH(UJ^)vF8eD<2Ho&`y!DkN#q-&{!remAbqo$J+@lo-K01Skh(Bixob)KrshOtvRkxpu+ z&ptIZCLYj+r{d|v#JGfiQWGi7Oex{0R-Y{6GiI8T>X4Q+%`d<}7YvI&(He>kB>@>D z5f`7+X2e$}Qlnz$VKt@VjggWv zMuUVQSR=a*VpD{u5(&w2&@!oxWU^5gpTeydhSl0+cKmv|rlwW~fv(Rtq5bofw#TZF z`yCG&TyUV1r|Bz3Hx2N{^5k+m`;3&kz*MZrRLKEcX;79Ucn?{b+8BQgd^U=US|#r3 zaXf^?L`v*dXT<#@BZ)XB9XqU{FW`gGHT20{FpAmQD8VX;g|LiKUB?i;oN**&uGYNc z{oieri~bdrEd=p`!xA;BUFhA9cc4$s$Me(YPhfFBkHtMe*ggD3zKn~rpRrA4$C$fJ zOnA+C&H1=v(*AjQn!ZH4!2r$>1a(Gxy*abFmx?nhS?$O;=g(SY?6>eV^=|^bN=GYi#kQUA3}6ssTr6W zK_`})So4dCf_lzf$lqLwQFh?GLkNq4Du~WIn8q)i**$fe|Q&VKann5Zc`Ucu@Go=yIPEjs!^RBb zAV`)Z4cX|?YAS%&i#k54_gQ7OiCPOI#S#BT-&U~A&qCo0Yr}m9=iIb&x$@nHZqRg1 z1?6^|wVSjT*ZNwIf&1Zgm8@L$ZHX-~W{3iS)+!MYN iZetE^2#pND1OTS+plJNyNE;};*(e%dDcS(wSneNZ5Zg5X delta 1320 zcmd5+TSyd97@pZVx;s1Tj)7~fi5^#|1lOHeUB}CVmPwF4XiGOJ>gXPAaGVu)6-33= zB9w%RBSj=s1VTg?8;nR_ECVCx@={1a_>yE0zVsAb&g`b77};A-Gw1u}eCPlE|CdnTY8zLdP%5vPN#ouBd5zsq0q16Bi8VIO z^>22QoWWf3yhb3ti&o?Eh1w*e_VfhrqJ6g7=NR#*^O;jh8Fl;+7VLAhHIMBF1_YSQ zDE!IRb$j0JQA3_GSX@#p6$gs)p(lddRUDy^=Ww_~Z-&7WSPO-952d;=YBveLBr9Zk zIE@UAxcU@IMAfK}U@+Rz)PzHDA8Kq2CxnpNj?Gg|r4p|! zdnMV_*H8o2OgJd{t0e_`;otR|bo$wEGQ9(?Y--nbYW?A_*bT@^q5RKwJrzORiK#)1 zLRuFLs(NJEWh>G&vtA7bQ8SA7YmH6G>kp8ZBk6&SYOFXjcN*VDtsG548L?h1+Afm~|uxbu<`d zLRP;&9*lwVb%R0bCwzT7bzN;iaED*=%Xz+4D_UH9Ofr3`PW "${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",