(function () { const STORAGE_KEY = "rename_mvp_session_id"; const state = { sessionId: initSessionId(), selectedSeries: null, episodes: [], selectedEpisodes: [], selectedFiles: [], selectedPairIndex: null, roots: [], modalFolders: [], modalFiles: [], modalSelectedFilePaths: new Set(), syncScrolling: false, }; 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"), episodeMeta: document.getElementById("episodeMeta"), refreshSelectedEpisodesBtn: document.getElementById("refreshSelectedEpisodesBtn"), clearSelectedEpisodesBtn: document.getElementById("clearSelectedEpisodesBtn"), episodeUpBtn: document.getElementById("episodeUpBtn"), episodeDownBtn: document.getElementById("episodeDownBtn"), episodeRemoveBtn: document.getElementById("episodeRemoveBtn"), selectedEpisodesList: document.getElementById("selectedEpisodesList"), fileMeta: document.getElementById("fileMeta"), selectFilesBtn: document.getElementById("selectFilesBtn"), refreshSelectedFilesBtn: document.getElementById("refreshSelectedFilesBtn"), clearSelectedFilesBtn: document.getElementById("clearSelectedFilesBtn"), fileUpBtn: document.getElementById("fileUpBtn"), fileDownBtn: document.getElementById("fileDownBtn"), fileRemoveBtn: document.getElementById("fileRemoveBtn"), selectedFilesList: document.getElementById("selectedFilesList"), filenamePreviewBtn: document.getElementById("filenamePreviewBtn"), renameExecuteBtn: document.getElementById("renameExecuteBtn"), fileModal: document.getElementById("fileModal"), fileModalTitle: document.getElementById("fileModalTitle"), closeFileModalBtn: document.getElementById("closeFileModalBtn"), modalRootSelect: document.getElementById("modalRootSelect"), modalFolderSelect: document.getElementById("modalFolderSelect"), modalSubpathInput: document.getElementById("modalSubpathInput"), modalRecursiveInput: document.getElementById("modalRecursiveInput"), modalLoadFoldersBtn: document.getElementById("modalLoadFoldersBtn"), modalLoadFilesBtn: document.getElementById("modalLoadFilesBtn"), modalFoldersList: document.getElementById("modalFoldersList"), modalFilesList: document.getElementById("modalFilesList"), modalAddSelectedFilesBtn: document.getElementById("modalAddSelectedFilesBtn"), }; 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 makeBtn(label, handler, secondary) { const btn = document.createElement("button"); btn.textContent = label; if (secondary) btn.className = "secondary"; btn.addEventListener("click", handler); return btn; } function setBusy(btn, busy) { if (btn) btn.disabled = busy; } function updateMeta() { const epCount = state.selectedEpisodes.length; const fileCount = state.selectedFiles.length; const mismatch = epCount !== fileCount; const mismatchText = mismatch ? " mismatch" : " aligned"; el.episodeMeta.innerHTML = `Rows: ${epCount}`; el.fileMeta.innerHTML = `Rows: ${fileCount} ${mismatchText}`; } function selectPair(index) { state.selectedPairIndex = index; renderSelectedEpisodes(); renderSelectedFiles(); } function syncScroll(source, target) { if (state.syncScrolling) return; state.syncScrolling = true; target.scrollTop = source.scrollTop; requestAnimationFrame(() => { state.syncScrolling = false; }); } function renderSelectedEpisodes() { el.selectedEpisodesList.innerHTML = ""; state.selectedEpisodes.forEach((item, idx) => { const li = document.createElement("li"); if (state.selectedPairIndex === idx) li.classList.add("selected"); const left = document.createElement("span"); const badge = document.createElement("span"); badge.className = "badge"; badge.textContent = `#${idx + 1}`; left.appendChild(badge); left.appendChild(document.createTextNode(item.episode.label || item.episode.title || "(episode)")); const right = document.createElement("div"); right.appendChild(makeBtn("Select", () => selectPair(idx), true)); li.appendChild(left); li.appendChild(right); li.addEventListener("click", () => selectPair(idx)); el.selectedEpisodesList.appendChild(li); }); updateMeta(); } function renderSelectedFiles() { el.selectedFilesList.innerHTML = ""; state.selectedFiles.forEach((item, idx) => { const li = document.createElement("li"); if (state.selectedPairIndex === idx) li.classList.add("selected"); const left = document.createElement("span"); const badge = document.createElement("span"); badge.className = "badge"; badge.textContent = `#${idx + 1}`; left.appendChild(badge); left.appendChild(document.createTextNode(item.file.path || item.file.name || "(file)")); const right = document.createElement("div"); right.appendChild(makeBtn("Select", () => selectPair(idx), true)); li.appendChild(left); li.appendChild(right); li.addEventListener("click", () => selectPair(idx)); el.selectedFilesList.appendChild(li); }); updateMeta(); } async function loadRoots() { const data = await api("/api/files/roots"); state.roots = data.items || []; el.modalRootSelect.innerHTML = ""; state.roots.forEach((root) => { const opt = document.createElement("option"); opt.value = root.id; opt.textContent = `${root.id}: ${root.path}`; el.modalRootSelect.appendChild(opt); }); if (state.roots.length) { el.modalSubpathInput.value = ""; await loadModalFolders(); } } 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 || "(series)"; const right = document.createElement("div"); right.appendChild(makeBtn("Select", async () => { state.selectedSeries = item; el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`; await loadEpisodes(); })); 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"); right.appendChild(makeBtn("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, }; await api(q("/api/session/selected-episodes"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: [payload] }), }); await loadSelectedEpisodes(); })); 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")); state.selectedEpisodes = data.items || []; if (state.selectedPairIndex != null && state.selectedPairIndex >= state.selectedEpisodes.length) { state.selectedPairIndex = state.selectedEpisodes.length - 1; } renderSelectedEpisodes(); return data; } async function loadSelectedFiles() { const data = await api(q("/api/session/selected-files")); state.selectedFiles = data.items || []; if (state.selectedPairIndex != null && state.selectedPairIndex >= state.selectedFiles.length) { state.selectedPairIndex = state.selectedFiles.length - 1; } renderSelectedFiles(); return data; } function openFileModal() { state.modalSelectedFilePaths = new Set(); el.fileModalTitle.textContent = "File Discovery"; el.modalAddSelectedFilesBtn.style.display = ""; el.modalLoadFilesBtn.style.display = ""; el.fileModal.classList.remove("hidden"); el.fileModal.setAttribute("aria-hidden", "false"); } function closeFileModal() { el.fileModal.classList.add("hidden"); el.fileModal.setAttribute("aria-hidden", "true"); } async function loadModalFolders() { const rootId = el.modalRootSelect.value; if (!rootId) throw new Error("No root selected"); const subpath = (el.modalSubpathInput.value || "").trim(); const data = await api( `/api/files/folders?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(subpath)}&limit=5000` ); state.modalFolders = data.items || []; el.modalFolderSelect.innerHTML = ""; const placeholder = document.createElement("option"); placeholder.value = ""; placeholder.textContent = state.modalFolders.length ? "Choose folder..." : "(No folders)"; el.modalFolderSelect.appendChild(placeholder); el.modalFoldersList.innerHTML = ""; state.modalFolders.forEach((folder) => { const opt = document.createElement("option"); opt.value = folder.subpath; opt.textContent = folder.subpath || folder.name; el.modalFolderSelect.appendChild(opt); const li = document.createElement("li"); const left = document.createElement("span"); left.textContent = folder.subpath || folder.name; const right = document.createElement("div"); right.appendChild(makeBtn("Select", () => { el.modalFolderSelect.value = folder.subpath; el.modalSubpathInput.value = folder.subpath; }, true)); li.appendChild(left); li.appendChild(right); el.modalFoldersList.appendChild(li); }); out("Folders loaded", data); } async function loadModalFiles() { const rootId = el.modalRootSelect.value; const subpath = (el.modalFolderSelect.value || "").trim(); if (!rootId) throw new Error("No root selected"); if (!subpath) throw new Error("Choose a folder first"); el.modalSubpathInput.value = subpath; const recursive = el.modalRecursiveInput.checked ? "true" : "false"; const data = await api( `/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(subpath)}&recursive=${recursive}&limit=200` ); state.modalFiles = data.items || []; el.modalFilesList.innerHTML = ""; state.modalSelectedFilePaths = new Set(); state.modalFiles.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(makeBtn("Toggle", () => { if (state.modalSelectedFilePaths.has(file.path)) { state.modalSelectedFilePaths.delete(file.path); li.classList.remove("selected"); } else { state.modalSelectedFilePaths.add(file.path); li.classList.add("selected"); } }, true)); li.appendChild(left); li.appendChild(right); el.modalFilesList.appendChild(li); }); out("Discovered files", data); } async function addModalSelectedFiles() { const selected = state.modalFiles.filter((f) => state.modalSelectedFilePaths.has(f.path)); if (!selected.length) throw new Error("Select at least one file"); const payload = selected.map((f) => ({ path: f.path, name: f.name })); await api(q("/api/session/selected-files"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: payload }), }); await loadSelectedFiles(); closeFileModal(); } async function reorderSelectedEpisodes(delta) { if (state.selectedPairIndex == null) throw new Error("Select an episode row first"); const from = state.selectedPairIndex; const to = from + delta; if (to < 0 || to >= state.selectedEpisodes.length) return; await api(q("/api/session/selected-episodes/reorder"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from_index: from, to_index: to }), }); state.selectedPairIndex = to; await loadSelectedEpisodes(); } async function reorderSelectedFiles(delta) { if (state.selectedPairIndex == null) throw new Error("Select a file row first"); const from = state.selectedPairIndex; const to = from + delta; if (to < 0 || to >= state.selectedFiles.length) return; await api(q("/api/session/selected-files/reorder"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from_index: from, to_index: to }), }); state.selectedPairIndex = to; await loadSelectedFiles(); } async function removeSelectedEpisode() { if (state.selectedPairIndex == null) throw new Error("Select an episode row first"); const item = state.selectedEpisodes[state.selectedPairIndex]; if (!item) return; await api(q(`/api/session/selected-episodes/${item.selection_id}`), { method: "DELETE" }); await loadSelectedEpisodes(); } async function removeSelectedFile() { if (state.selectedPairIndex == null) throw new Error("Select a file row first"); const item = state.selectedFiles[state.selectedPairIndex]; if (!item) return; await api(q(`/api/session/selected-files/${item.selection_id}`), { method: "DELETE" }); await loadSelectedFiles(); } async function executeRename() { const data = await api(q("/api/session/rename-execute") + "&confirm=true", { method: "POST" }); if (data.executed) { out("Rename execute: success", data); const renamedFiles = (data.items || []) .filter((i) => i.status === "renamed") .map((i) => ({ path: i.destination_path, name: i.proposed_filename })); await api(q("/api/session/selected-files"), { method: "DELETE" }); if (renamedFiles.length) { 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) { try { setBusy(btn, true); await fn(); } catch (err) { out("Error", { detail: err.message || String(err) }); } finally { setBusy(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 () => { await api(q("/api/session/selected-episodes"), { method: "DELETE" }); state.selectedPairIndex = null; await loadSelectedEpisodes(); }, el.clearSelectedEpisodesBtn) ); el.episodeUpBtn.addEventListener("click", () => withHandler(() => reorderSelectedEpisodes(-1), el.episodeUpBtn)); el.episodeDownBtn.addEventListener("click", () => withHandler(() => reorderSelectedEpisodes(1), el.episodeDownBtn)); el.episodeRemoveBtn.addEventListener("click", () => withHandler(removeSelectedEpisode, el.episodeRemoveBtn)); el.selectFilesBtn.addEventListener("click", () => withHandler(async () => { openFileModal(); await loadRoots(); }, el.selectFilesBtn)); el.refreshSelectedFilesBtn.addEventListener("click", () => withHandler(loadSelectedFiles, el.refreshSelectedFilesBtn)); el.clearSelectedFilesBtn.addEventListener("click", () => withHandler(async () => { await api(q("/api/session/selected-files"), { method: "DELETE" }); state.selectedPairIndex = null; await loadSelectedFiles(); }, el.clearSelectedFilesBtn) ); el.fileUpBtn.addEventListener("click", () => withHandler(() => reorderSelectedFiles(-1), el.fileUpBtn)); el.fileDownBtn.addEventListener("click", () => withHandler(() => reorderSelectedFiles(1), el.fileDownBtn)); el.fileRemoveBtn.addEventListener("click", () => withHandler(removeSelectedFile, el.fileRemoveBtn)); el.filenamePreviewBtn.addEventListener("click", () => withHandler(async () => out("Filename preview", await api(q("/api/session/filename-preview"))), el.filenamePreviewBtn)); el.renameExecuteBtn.addEventListener("click", () => withHandler(executeRename, el.renameExecuteBtn)); el.closeFileModalBtn.addEventListener("click", closeFileModal); el.fileModal.addEventListener("click", (e) => { if (e.target === el.fileModal) closeFileModal(); }); el.modalRootSelect.addEventListener("change", () => withHandler(loadModalFolders, el.modalRootSelect)); el.modalLoadFoldersBtn.addEventListener("click", () => withHandler(loadModalFolders, el.modalLoadFoldersBtn)); el.modalLoadFilesBtn.addEventListener("click", () => withHandler(loadModalFiles, el.modalLoadFilesBtn)); el.modalFolderSelect.addEventListener("change", () => { const sub = (el.modalFolderSelect.value || "").trim(); if (sub) el.modalSubpathInput.value = sub; }); el.modalAddSelectedFilesBtn.addEventListener("click", () => withHandler(addModalSelectedFiles, el.modalAddSelectedFilesBtn)); el.selectedEpisodesList.addEventListener("scroll", () => syncScroll(el.selectedEpisodesList, el.selectedFilesList)); el.selectedFilesList.addEventListener("scroll", () => syncScroll(el.selectedFilesList, el.selectedEpisodesList)); } async function init() { el.sessionMeta.textContent = `session_id: ${state.sessionId}`; el.modalRecursiveInput.checked = true; bindEvents(); await loadSelectedEpisodes(); await loadSelectedFiles(); await loadRoots(); } init().catch((err) => out("Init error", { detail: err.message || String(err) })); })();