feat & Bugfix: layout en rename (year)

This commit is contained in:
kodi
2026-03-08 07:41:24 +01:00
parent 6bf753e3b7
commit 06c144d2fc
9 changed files with 181 additions and 20 deletions
+17
View File
@@ -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))
+47
View File
@@ -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:
+12
View File
@@ -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")
+78 -5
View File
@@ -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();
+8 -1
View File
@@ -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;
+10 -7
View File
@@ -14,7 +14,7 @@
<main class="grid">
<section class="panel" id="panelSearch">
<h2>Panel 1: TVDB Search</h2>
<h2>1. TV Shows</h2>
<div class="row">
<input id="searchInput" type="text" placeholder="Search series..." />
<button id="searchBtn">Search</button>
@@ -24,7 +24,7 @@
</section>
<section class="panel" id="panelEpisodes">
<h2>Panel 2: Episodes</h2>
<h2>2. Episodes</h2>
<div class="row">
<button id="refreshEpisodesBtn">Refresh Episodes</button>
</div>
@@ -32,7 +32,7 @@
</section>
<section class="panel" id="panelSelectedEpisodes">
<h2>Panel 3: Selected Episodes</h2>
<h2>3. Selected Episodes</h2>
<div class="row">
<button id="refreshSelectedEpisodesBtn">Refresh</button>
<button id="clearSelectedEpisodesBtn">Clear</button>
@@ -41,20 +41,23 @@
</section>
<section class="panel" id="panelSelectedFiles">
<h2>Panel 4: Selected Files + File Discovery</h2>
<h2>4. Selected Files</h2>
<div class="stack">
<div class="row">
<label for="rootsSelect">Root</label>
<select id="rootsSelect"></select>
<label for="foldersSelect">Folder</label>
<select id="foldersSelect"></select>
<button id="refreshRootsBtn">Refresh Roots</button>
</div>
<div class="row">
<input id="subpathInput" type="text" placeholder="Subpath (relative)" />
<input id="subpathInput" type="text" placeholder="Current subpath (relative)" />
<button id="loadFoldersBtn">Load Folders</button>
<button id="loadFilesBtn">Load Files</button>
<label>
<input id="recursiveInput" type="checkbox" />
<input id="recursiveInput" type="checkbox" checked />
recursive
</label>
<button id="discoverBtn">Discover</button>
</div>
<div>
<h3>Discovered Files</h3>
Binary file not shown.
+3 -3
View File
@@ -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
+6 -4
View File
@@ -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",