feat & Bugfix: layout en rename (year)
This commit is contained in:
@@ -28,3 +28,20 @@ def discover_files(
|
|||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(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,
|
"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]:
|
def _load_allowed_extensions(self) -> set[str]:
|
||||||
raw = os.getenv("ALLOWED_EXTENSIONS", "").strip()
|
raw = os.getenv("ALLOWED_EXTENSIONS", "").strip()
|
||||||
if raw:
|
if raw:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -410,6 +411,7 @@ class SessionService:
|
|||||||
or "Unknown Series"
|
or "Unknown Series"
|
||||||
)
|
)
|
||||||
year = episode.get("year") or "0000"
|
year = episode.get("year") or "0000"
|
||||||
|
series = self._normalize_series_name(series, year)
|
||||||
title = episode.get("title") or "Untitled"
|
title = episode.get("title") or "Untitled"
|
||||||
|
|
||||||
season_raw = episode.get("season_number") or episode.get("season") or 0
|
season_raw = episode.get("season_number") or episode.get("season") or 0
|
||||||
@@ -449,6 +451,16 @@ class SessionService:
|
|||||||
"items": previews,
|
"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:
|
def execute_rename(self, session_id: str, confirm: bool) -> dict:
|
||||||
if not confirm:
|
if not confirm:
|
||||||
raise ValueError("confirm=true is required to execute rename")
|
raise ValueError("confirm=true is required to execute rename")
|
||||||
|
|||||||
+77
-4
@@ -6,7 +6,9 @@
|
|||||||
selectedSeries: null,
|
selectedSeries: null,
|
||||||
episodes: [],
|
episodes: [],
|
||||||
roots: [],
|
roots: [],
|
||||||
|
folders: [],
|
||||||
discoveredFiles: [],
|
discoveredFiles: [],
|
||||||
|
currentSubpath: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
@@ -22,10 +24,12 @@
|
|||||||
clearSelectedEpisodesBtn: document.getElementById("clearSelectedEpisodesBtn"),
|
clearSelectedEpisodesBtn: document.getElementById("clearSelectedEpisodesBtn"),
|
||||||
selectedEpisodesList: document.getElementById("selectedEpisodesList"),
|
selectedEpisodesList: document.getElementById("selectedEpisodesList"),
|
||||||
rootsSelect: document.getElementById("rootsSelect"),
|
rootsSelect: document.getElementById("rootsSelect"),
|
||||||
|
foldersSelect: document.getElementById("foldersSelect"),
|
||||||
refreshRootsBtn: document.getElementById("refreshRootsBtn"),
|
refreshRootsBtn: document.getElementById("refreshRootsBtn"),
|
||||||
subpathInput: document.getElementById("subpathInput"),
|
subpathInput: document.getElementById("subpathInput"),
|
||||||
|
loadFoldersBtn: document.getElementById("loadFoldersBtn"),
|
||||||
|
loadFilesBtn: document.getElementById("loadFilesBtn"),
|
||||||
recursiveInput: document.getElementById("recursiveInput"),
|
recursiveInput: document.getElementById("recursiveInput"),
|
||||||
discoverBtn: document.getElementById("discoverBtn"),
|
|
||||||
discoveredFilesList: document.getElementById("discoveredFilesList"),
|
discoveredFilesList: document.getElementById("discoveredFilesList"),
|
||||||
refreshSelectedFilesBtn: document.getElementById("refreshSelectedFilesBtn"),
|
refreshSelectedFilesBtn: document.getElementById("refreshSelectedFilesBtn"),
|
||||||
clearSelectedFilesBtn: document.getElementById("clearSelectedFilesBtn"),
|
clearSelectedFilesBtn: document.getElementById("clearSelectedFilesBtn"),
|
||||||
@@ -89,6 +93,11 @@
|
|||||||
opt.textContent = `${root.id}: ${root.path}`;
|
opt.textContent = `${root.id}: ${root.path}`;
|
||||||
el.rootsSelect.appendChild(opt);
|
el.rootsSelect.appendChild(opt);
|
||||||
}
|
}
|
||||||
|
if (state.roots.length > 0) {
|
||||||
|
state.currentSubpath = "";
|
||||||
|
el.subpathInput.value = "";
|
||||||
|
await loadFolders();
|
||||||
|
}
|
||||||
out("Roots loaded", data);
|
out("Roots loaded", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,10 +201,40 @@
|
|||||||
return data;
|
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() {
|
async function discoverFiles() {
|
||||||
const rootId = el.rootsSelect.value;
|
const rootId = el.rootsSelect.value;
|
||||||
if (!rootId) throw new Error("No root selected");
|
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 recursive = el.recursiveInput.checked ? "true" : "false";
|
||||||
const data = await api(`/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${subpath}&recursive=${recursive}&limit=200`);
|
const data = await api(`/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${subpath}&recursive=${recursive}&limit=200`);
|
||||||
state.discoveredFiles = data.items || [];
|
state.discoveredFiles = data.items || [];
|
||||||
@@ -269,8 +308,28 @@
|
|||||||
const data = await api(q("/api/session/rename-execute") + "&confirm=true", {
|
const data = await api(q("/api/session/rename-execute") + "&confirm=true", {
|
||||||
method: "POST",
|
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();
|
await loadSelectedFiles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
out("Rename execute: preflight failed", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withHandler(fn, btn) {
|
async function withHandler(fn, btn) {
|
||||||
@@ -296,7 +355,20 @@
|
|||||||
}, el.clearSelectedEpisodesBtn)
|
}, el.clearSelectedEpisodesBtn)
|
||||||
);
|
);
|
||||||
el.refreshRootsBtn.addEventListener("click", () => withHandler(loadRoots, el.refreshRootsBtn));
|
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.refreshSelectedFilesBtn.addEventListener("click", () => withHandler(loadSelectedFiles, el.refreshSelectedFilesBtn));
|
||||||
el.clearSelectedFilesBtn.addEventListener("click", () =>
|
el.clearSelectedFilesBtn.addEventListener("click", () =>
|
||||||
withHandler(async () => {
|
withHandler(async () => {
|
||||||
@@ -312,6 +384,7 @@
|
|||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
el.sessionMeta.textContent = `session_id: ${state.sessionId}`;
|
el.sessionMeta.textContent = `session_id: ${state.sessionId}`;
|
||||||
|
el.recursiveInput.checked = true;
|
||||||
bindEvents();
|
bindEvents();
|
||||||
await loadRoots();
|
await loadRoots();
|
||||||
await loadSelectedEpisodes();
|
await loadSelectedEpisodes();
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ body {
|
|||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
grid-template-columns: repeat(4, minmax(280px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@@ -131,6 +132,12 @@ button.secondary {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1600px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<main class="grid">
|
<main class="grid">
|
||||||
<section class="panel" id="panelSearch">
|
<section class="panel" id="panelSearch">
|
||||||
<h2>Panel 1: TVDB Search</h2>
|
<h2>1. TV Shows</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input id="searchInput" type="text" placeholder="Search series..." />
|
<input id="searchInput" type="text" placeholder="Search series..." />
|
||||||
<button id="searchBtn">Search</button>
|
<button id="searchBtn">Search</button>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="panelEpisodes">
|
<section class="panel" id="panelEpisodes">
|
||||||
<h2>Panel 2: Episodes</h2>
|
<h2>2. Episodes</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="refreshEpisodesBtn">Refresh Episodes</button>
|
<button id="refreshEpisodesBtn">Refresh Episodes</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="panelSelectedEpisodes">
|
<section class="panel" id="panelSelectedEpisodes">
|
||||||
<h2>Panel 3: Selected Episodes</h2>
|
<h2>3. Selected Episodes</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="refreshSelectedEpisodesBtn">Refresh</button>
|
<button id="refreshSelectedEpisodesBtn">Refresh</button>
|
||||||
<button id="clearSelectedEpisodesBtn">Clear</button>
|
<button id="clearSelectedEpisodesBtn">Clear</button>
|
||||||
@@ -41,20 +41,23 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="panelSelectedFiles">
|
<section class="panel" id="panelSelectedFiles">
|
||||||
<h2>Panel 4: Selected Files + File Discovery</h2>
|
<h2>4. Selected Files</h2>
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="rootsSelect">Root</label>
|
<label for="rootsSelect">Root</label>
|
||||||
<select id="rootsSelect"></select>
|
<select id="rootsSelect"></select>
|
||||||
|
<label for="foldersSelect">Folder</label>
|
||||||
|
<select id="foldersSelect"></select>
|
||||||
<button id="refreshRootsBtn">Refresh Roots</button>
|
<button id="refreshRootsBtn">Refresh Roots</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<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>
|
<label>
|
||||||
<input id="recursiveInput" type="checkbox" />
|
<input id="recursiveInput" type="checkbox" checked />
|
||||||
recursive
|
recursive
|
||||||
</label>
|
</label>
|
||||||
<button id="discoverBtn">Discover</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>Discovered Files</h3>
|
<h3>Discovered Files</h3>
|
||||||
|
|||||||
Binary file not shown.
@@ -29,8 +29,8 @@ cat > "${TMP_DIR}/episodes_payload.json" <<'JSON'
|
|||||||
"items": [
|
"items": [
|
||||||
{
|
{
|
||||||
"id": 9784113,
|
"id": 9784113,
|
||||||
"series": "Elsbeth",
|
"series": "All's Fair (2025)",
|
||||||
"year": "2024",
|
"year": "2025",
|
||||||
"season_number": 1,
|
"season_number": 1,
|
||||||
"episode_number": 1,
|
"episode_number": 1,
|
||||||
"title": "Pilot"
|
"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, dict), "response must be an object"
|
||||||
assert isinstance(data.get("items"), list), "items must be a list"
|
assert isinstance(data.get("items"), list), "items must be a list"
|
||||||
assert len(data["items"]) == 2, "expected 2 preview items"
|
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"
|
assert data["items"][1]["proposed_filename"] == "Elsbeth (2024) - S01E02 - A Classic New York Character.mp4", "second proposed filename mismatch"
|
||||||
print("filename preview validation passed")
|
print("filename preview validation passed")
|
||||||
PY
|
PY
|
||||||
|
|||||||
+6
-4
@@ -26,10 +26,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
html = Path(sys.argv[1]).read_text(encoding="utf-8")
|
html = Path(sys.argv[1]).read_text(encoding="utf-8")
|
||||||
required = [
|
required = [
|
||||||
"Panel 1: TVDB Search",
|
"1. TV Shows",
|
||||||
"Panel 2: Episodes",
|
"2. Episodes",
|
||||||
"Panel 3: Selected Episodes",
|
"3. Selected Episodes",
|
||||||
"Panel 4: Selected Files + File Discovery",
|
"4. Selected Files",
|
||||||
"Mapping Preview",
|
"Mapping Preview",
|
||||||
"Filename Preview",
|
"Filename Preview",
|
||||||
"Rename Execute (confirm=true)",
|
"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 "session_id" in js, "app.js missing session_id usage"
|
||||||
assert "/api/tvdb/search" in js, "app.js missing search endpoint 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/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")
|
print("static assets validation passed")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ required_endpoints = [
|
|||||||
"/api/session/selected-episodes",
|
"/api/session/selected-episodes",
|
||||||
"/api/session/selected-files",
|
"/api/session/selected-files",
|
||||||
"/api/files/roots",
|
"/api/files/roots",
|
||||||
|
"/api/files/folders",
|
||||||
"/api/files/discover",
|
"/api/files/discover",
|
||||||
"/api/session/mapping-preview",
|
"/api/session/mapping-preview",
|
||||||
"/api/session/filename-preview",
|
"/api/session/filename-preview",
|
||||||
|
|||||||
Reference in New Issue
Block a user