diff --git a/app/main.py b/app/main.py index f112cc0..5f0ae45 100644 --- a/app/main.py +++ b/app/main.py @@ -1,15 +1,25 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates from app.api.files import router as files_router from app.api.session import router as session_router from app.api.tvdb import router as tvdb_router app = FastAPI(title="Rename MVP") +templates = Jinja2Templates(directory="app/templates") app.include_router(tvdb_router, prefix="/api/tvdb", tags=["tvdb"]) app.include_router(session_router, prefix="/api/session", tags=["session"]) app.include_router(files_router, prefix="/api/files", tags=["files"]) +app.mount("/static", StaticFiles(directory="app/static"), name="static") @app.get("/api/health") def health(): return {"status": "ok"} + + +@app.get("/", response_class=HTMLResponse) +def index(request: Request): + return templates.TemplateResponse("index.html", {"request": request}) diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..96f3601 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,322 @@ +(function () { + const STORAGE_KEY = "rename_mvp_session_id"; + const sessionId = initSessionId(); + const state = { + sessionId, + selectedSeries: null, + episodes: [], + roots: [], + discoveredFiles: [], + }; + + const el = { + sessionMeta: document.getElementById("sessionMeta"), + outputBox: document.getElementById("outputBox"), + searchInput: document.getElementById("searchInput"), + searchBtn: document.getElementById("searchBtn"), + searchResults: document.getElementById("searchResults"), + seriesInfo: document.getElementById("seriesInfo"), + refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"), + episodesList: document.getElementById("episodesList"), + refreshSelectedEpisodesBtn: document.getElementById("refreshSelectedEpisodesBtn"), + clearSelectedEpisodesBtn: document.getElementById("clearSelectedEpisodesBtn"), + selectedEpisodesList: document.getElementById("selectedEpisodesList"), + rootsSelect: document.getElementById("rootsSelect"), + refreshRootsBtn: document.getElementById("refreshRootsBtn"), + subpathInput: document.getElementById("subpathInput"), + recursiveInput: document.getElementById("recursiveInput"), + discoverBtn: document.getElementById("discoverBtn"), + discoveredFilesList: document.getElementById("discoveredFilesList"), + refreshSelectedFilesBtn: document.getElementById("refreshSelectedFilesBtn"), + clearSelectedFilesBtn: document.getElementById("clearSelectedFilesBtn"), + selectedFilesList: document.getElementById("selectedFilesList"), + mappingPreviewBtn: document.getElementById("mappingPreviewBtn"), + filenamePreviewBtn: document.getElementById("filenamePreviewBtn"), + renameExecuteBtn: document.getElementById("renameExecuteBtn"), + }; + + function initSessionId() { + const existing = localStorage.getItem(STORAGE_KEY); + if (existing) return existing; + const created = "ui-" + Date.now() + "-" + Math.floor(Math.random() * 10000); + localStorage.setItem(STORAGE_KEY, created); + return created; + } + + function q(path) { + return path.includes("?") ? `${path}&session_id=${encodeURIComponent(state.sessionId)}` : `${path}?session_id=${encodeURIComponent(state.sessionId)}`; + } + + async function api(path, options = {}) { + const resp = await fetch(path, options); + const text = await resp.text(); + let data = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch (_err) { + data = { raw: text }; + } + if (!resp.ok) { + throw new Error(data.detail || data.raw || `HTTP ${resp.status}`); + } + return data; + } + + function out(label, payload) { + el.outputBox.textContent = `${label}\n${JSON.stringify(payload, null, 2)}`; + } + + function makeActionButton(label, handler, secondary) { + const btn = document.createElement("button"); + btn.textContent = label; + if (secondary) btn.className = "secondary"; + btn.addEventListener("click", handler); + return btn; + } + + function setLoading(btn, loading) { + if (!btn) return; + btn.disabled = loading; + } + + async function loadRoots() { + const data = await api("/api/files/roots"); + state.roots = data.items || []; + el.rootsSelect.innerHTML = ""; + for (const root of state.roots) { + const opt = document.createElement("option"); + opt.value = root.id; + opt.textContent = `${root.id}: ${root.path}`; + el.rootsSelect.appendChild(opt); + } + out("Roots loaded", data); + } + + async function doSearch() { + const query = (el.searchInput.value || "").trim(); + if (!query) return; + const data = await api(`/api/tvdb/search?q=${encodeURIComponent(query)}`); + el.searchResults.innerHTML = ""; + (data.items || []).forEach((item) => { + const li = document.createElement("li"); + const left = document.createElement("span"); + left.textContent = item.display_name || item.name || "(no name)"; + const right = document.createElement("div"); + const btn = makeActionButton("Select", async () => { + state.selectedSeries = item; + el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`; + await loadEpisodes(); + }); + right.appendChild(btn); + li.appendChild(left); + li.appendChild(right); + el.searchResults.appendChild(li); + }); + out("Search result", data); + } + + async function loadEpisodes() { + if (!state.selectedSeries || !state.selectedSeries.id) { + throw new Error("Select a series first"); + } + const data = await api(`/api/tvdb/series/${encodeURIComponent(state.selectedSeries.id)}/episodes?order_type=aired`); + state.episodes = data.items || []; + el.episodesList.innerHTML = ""; + + state.episodes.forEach((episode) => { + const li = document.createElement("li"); + const left = document.createElement("span"); + left.textContent = episode.label || `${episode.season_number}x${episode.episode_number} ${episode.title}`; + const right = document.createElement("div"); + const btn = makeActionButton("Add", async () => { + const payload = { + id: episode.id, + series: state.selectedSeries.name, + year: state.selectedSeries.year, + season_number: episode.season_number, + episode_number: episode.episode_number, + title: episode.title, + aired: episode.aired, + label: episode.label, + }; + const res = await api(q("/api/session/selected-episodes"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: [payload] }), + }); + await loadSelectedEpisodes(); + out("Episode added", res); + }); + right.appendChild(btn); + li.appendChild(left); + li.appendChild(right); + el.episodesList.appendChild(li); + }); + out("Episodes loaded", data); + } + + async function loadSelectedEpisodes() { + const data = await api(q("/api/session/selected-episodes")); + el.selectedEpisodesList.innerHTML = ""; + (data.items || []).forEach((item, idx) => { + const li = document.createElement("li"); + const left = document.createElement("span"); + left.textContent = item.episode.label || item.episode.title || `Episode #${idx + 1}`; + const right = document.createElement("div"); + right.appendChild(makeActionButton("Up", async () => { + if (idx === 0) return; + await api(q("/api/session/selected-episodes/reorder"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from_index: idx, to_index: idx - 1 }), + }); + await loadSelectedEpisodes(); + }, true)); + right.appendChild(makeActionButton("Down", async () => { + if (idx >= data.items.length - 1) return; + await api(q("/api/session/selected-episodes/reorder"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from_index: idx, to_index: idx + 1 }), + }); + await loadSelectedEpisodes(); + }, true)); + right.appendChild(makeActionButton("Remove", async () => { + await api(q(`/api/session/selected-episodes/${item.selection_id}`), { method: "DELETE" }); + await loadSelectedEpisodes(); + }, true)); + li.appendChild(left); + li.appendChild(right); + el.selectedEpisodesList.appendChild(li); + }); + return 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 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 || []; + el.discoveredFilesList.innerHTML = ""; + + state.discoveredFiles.forEach((file) => { + const li = document.createElement("li"); + const left = document.createElement("span"); + left.textContent = file.relative_path || file.name; + const right = document.createElement("div"); + right.appendChild(makeActionButton("Add", async () => { + const payload = { path: file.path, name: file.name }; + const res = await api(q("/api/session/selected-files"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: [payload] }), + }); + await loadSelectedFiles(); + out("File added", res); + })); + li.appendChild(left); + li.appendChild(right); + el.discoveredFilesList.appendChild(li); + }); + out("Discover result", data); + } + + async function loadSelectedFiles() { + const data = await api(q("/api/session/selected-files")); + el.selectedFilesList.innerHTML = ""; + (data.items || []).forEach((item, idx) => { + const li = document.createElement("li"); + const left = document.createElement("span"); + left.textContent = item.file.path || item.file.name || `File #${idx + 1}`; + const right = document.createElement("div"); + right.appendChild(makeActionButton("Up", async () => { + if (idx === 0) return; + await api(q("/api/session/selected-files/reorder"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from_index: idx, to_index: idx - 1 }), + }); + await loadSelectedFiles(); + }, true)); + right.appendChild(makeActionButton("Down", async () => { + if (idx >= data.items.length - 1) return; + await api(q("/api/session/selected-files/reorder"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from_index: idx, to_index: idx + 1 }), + }); + await loadSelectedFiles(); + }, true)); + right.appendChild(makeActionButton("Remove", async () => { + await api(q(`/api/session/selected-files/${item.selection_id}`), { method: "DELETE" }); + await loadSelectedFiles(); + }, true)); + li.appendChild(left); + li.appendChild(right); + el.selectedFilesList.appendChild(li); + }); + return data; + } + + async function callPreview(path, label) { + const data = await api(q(path)); + out(label, data); + } + + async function executeRename() { + const data = await api(q("/api/session/rename-execute") + "&confirm=true", { + method: "POST", + }); + out("Rename execute", data); + await loadSelectedFiles(); + } + + async function withHandler(fn, btn) { + try { + setLoading(btn, true); + await fn(); + } catch (err) { + out("Error", { detail: err.message || String(err) }); + } finally { + setLoading(btn, false); + } + } + + function bindEvents() { + el.searchBtn.addEventListener("click", () => withHandler(doSearch, el.searchBtn)); + el.refreshEpisodesBtn.addEventListener("click", () => withHandler(loadEpisodes, el.refreshEpisodesBtn)); + el.refreshSelectedEpisodesBtn.addEventListener("click", () => withHandler(loadSelectedEpisodes, el.refreshSelectedEpisodesBtn)); + el.clearSelectedEpisodesBtn.addEventListener("click", () => + withHandler(async () => { + const res = await api(q("/api/session/selected-episodes"), { method: "DELETE" }); + await loadSelectedEpisodes(); + out("Selected episodes cleared", res); + }, el.clearSelectedEpisodesBtn) + ); + el.refreshRootsBtn.addEventListener("click", () => withHandler(loadRoots, el.refreshRootsBtn)); + el.discoverBtn.addEventListener("click", () => withHandler(discoverFiles, el.discoverBtn)); + el.refreshSelectedFilesBtn.addEventListener("click", () => withHandler(loadSelectedFiles, el.refreshSelectedFilesBtn)); + el.clearSelectedFilesBtn.addEventListener("click", () => + withHandler(async () => { + const res = await api(q("/api/session/selected-files"), { method: "DELETE" }); + await loadSelectedFiles(); + out("Selected files cleared", res); + }, el.clearSelectedFilesBtn) + ); + el.mappingPreviewBtn.addEventListener("click", () => withHandler(() => callPreview("/api/session/mapping-preview", "Mapping preview"), el.mappingPreviewBtn)); + el.filenamePreviewBtn.addEventListener("click", () => withHandler(() => callPreview("/api/session/filename-preview", "Filename preview"), el.filenamePreviewBtn)); + el.renameExecuteBtn.addEventListener("click", () => withHandler(executeRename, el.renameExecuteBtn)); + } + + async function init() { + el.sessionMeta.textContent = `session_id: ${state.sessionId}`; + bindEvents(); + await loadRoots(); + await loadSelectedEpisodes(); + await loadSelectedFiles(); + } + + init().catch((err) => out("Init error", { detail: err.message || String(err) })); +})(); diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..704c82e --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,138 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: "Segoe UI", Tahoma, sans-serif; + background: #f2f4f8; + color: #1a1f2b; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: #0f172a; + color: #e2e8f0; +} + +.topbar h1 { + margin: 0; + font-size: 20px; +} + +#sessionMeta { + font-size: 12px; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(280px, 1fr)); + gap: 12px; + padding: 12px; +} + +.panel { + background: #ffffff; + border: 1px solid #d7dee9; + border-radius: 8px; + padding: 10px; +} + +.panel h2 { + margin: 0 0 10px; + font-size: 16px; +} + +.panel h3 { + margin: 10px 0 6px; + font-size: 14px; +} + +.row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.stack { + display: flex; + flex-direction: column; +} + +input[type="text"], +select { + border: 1px solid #c3cedf; + border-radius: 6px; + padding: 6px 8px; + min-width: 160px; +} + +button { + border: 1px solid #0f172a; + background: #0f172a; + color: #ffffff; + border-radius: 6px; + padding: 6px 10px; + cursor: pointer; +} + +button.secondary { + background: #e2e8f0; + color: #1a1f2b; + border-color: #c3cedf; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + max-height: 260px; + overflow: auto; + border: 1px solid #e4eaf2; + border-radius: 6px; +} + +.list li { + display: flex; + justify-content: space-between; + gap: 8px; + border-bottom: 1px solid #edf1f7; + padding: 6px 8px; + font-size: 13px; +} + +.list li:last-child { + border-bottom: none; +} + +.muted { + color: #475569; + font-size: 12px; + margin-bottom: 8px; +} + +.output { + margin: 0 12px 12px; +} + +#outputBox { + margin: 0; + background: #0b1220; + color: #dbeafe; + border-radius: 6px; + padding: 10px; + max-height: 320px; + overflow: auto; + font-size: 12px; +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: 1fr; + } +} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..565511b --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,87 @@ + + + + + + Rename MVP + + + +
+

Rename MVP

+
+
+ +
+
+

Panel 1: TVDB Search

+
+ + +
+
+ +
+ +
+

Panel 2: Episodes

+
+ +
+ +
+ +
+

Panel 3: Selected Episodes

+
+ + +
+ +
+ +
+

Panel 4: Selected Files + File Discovery

+
+
+ + + +
+
+ + + +
+
+

Discovered Files

+
    +
    +
    + + +
    +
    +

    Selected Files

    +
      +
      +
      + + + +
      +
      +
      +
      + +
      +

      Output

      +
      
      +    
      + + + + diff --git a/data/session_state.sqlite3 b/data/session_state.sqlite3 index fa5c9b7..7b4364c 100644 Binary files a/data/session_state.sqlite3 and b/data/session_state.sqlite3 differ diff --git a/feature_tests_ui.sh b/feature_tests_ui.sh new file mode 100755 index 0000000..e3d480e --- /dev/null +++ b/feature_tests_ui.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ -z "${BASE_URL:-}" ]; then + if curl --silent --fail http://127.0.0.1:8085/api/health >/dev/null 2>&1; then + BASE_URL="http://127.0.0.1:8085" + elif curl --silent --fail http://host.containers.internal:8085/api/health >/dev/null 2>&1; then + BASE_URL="http://host.containers.internal:8085" + else + echo "ERROR: could not determine BASE_URL. Tried 127.0.0.1 and host.containers.internal." >&2 + exit 1 + fi +fi + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +echo "== Feature test 1: root page renders 4 panels and action controls ==" +curl --fail --silent --show-error \ + "${BASE_URL}/" \ + -o "${TMP_DIR}/index.html" + +python3 - "${TMP_DIR}/index.html" <<'PY' +import sys +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", + "Mapping Preview", + "Filename Preview", + "Rename Execute (confirm=true)", +] +for needle in required: + assert needle in html, f"missing UI text: {needle}" +print("UI panel/control validation passed") +PY + +echo +echo "== Feature test 2: static assets are served ==" +curl --fail --silent --show-error \ + "${BASE_URL}/static/styles.css" \ + -o "${TMP_DIR}/styles.css" +curl --fail --silent --show-error \ + "${BASE_URL}/static/app.js" \ + -o "${TMP_DIR}/app.js" + +python3 - "${TMP_DIR}/styles.css" "${TMP_DIR}/app.js" <<'PY' +import sys +from pathlib import Path + +css = Path(sys.argv[1]).read_text(encoding="utf-8") +js = Path(sys.argv[2]).read_text(encoding="utf-8") +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" +print("static assets validation passed") +PY + +echo +echo "== Feature test 3: UI references existing backend workflow endpoints ==" +python3 - "${TMP_DIR}/app.js" <<'PY' +import sys +from pathlib import Path + +js = Path(sys.argv[1]).read_text(encoding="utf-8") +required_endpoints = [ + "/api/tvdb/search", + "/api/tvdb/series/", + "/api/session/selected-episodes", + "/api/session/selected-files", + "/api/files/roots", + "/api/files/discover", + "/api/session/mapping-preview", + "/api/session/filename-preview", + "/api/session/rename-execute", +] +for endpoint in required_endpoints: + assert endpoint in js, f"missing endpoint usage in UI: {endpoint}" +print("endpoint wiring validation passed") +PY + +echo +echo "All UI feature tests passed."