feat & Bugfix: layout en rename (year)
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
+77
-4
@@ -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);
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user