(function () { const STORAGE_KEY = "rename_mvp_session_id"; const state = { sessionId: initSessionId(), selectedSeries: null, selectedSeriesSummary: 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"), seriesDetails: document.getElementById("seriesDetails"), seriesPoster: document.getElementById("seriesPoster"), seriesFirstAired: document.getElementById("seriesFirstAired"), seriesNetwork: document.getElementById("seriesNetwork"), seriesStatus: document.getElementById("seriesStatus"), seriesOverview: document.getElementById("seriesOverview"), seriesTvdbLink: document.getElementById("seriesTvdbLink"), 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"), 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) { const text = `${label}\n${JSON.stringify(payload, null, 2)}`; if (el.outputBox) { el.outputBox.textContent = text; return; } console.log(text); } 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 compactEpisodeText(episode) { const season = Number(episode.season_number); const number = Number(episode.episode_number); const title = (episode.title || "").toString().trim() || "(untitled)"; if (Number.isFinite(season) && Number.isFinite(number)) { return `S${season.toString().padStart(2, "0")}E${number.toString().padStart(2, "0")} - ${title}`; } return title; } function basename(pathText) { const text = (pathText || "").toString(); const normalized = text.replace(/\\/g, "/"); const idx = normalized.lastIndexOf("/"); return idx >= 0 ? normalized.slice(idx + 1) : normalized; } function fallbackText(value) { const text = (value || "").toString().trim(); return text || "-"; } function buildTvdbUrl(item) { const summary = state.selectedSeriesSummary || {}; const summaryUrl = (summary.tvdb_url || "").toString().trim(); if (summaryUrl) return summaryUrl; const raw = item.raw || {}; const slug = (summary.slug || raw.slug || "").toString().trim(); if (slug) return `https://www.thetvdb.com/series/${encodeURIComponent(slug)}`; const tvdbId = (raw.tvdb_id || item.id || "").toString().trim(); if (tvdbId) return `https://www.thetvdb.com/series/${encodeURIComponent(tvdbId)}`; return ""; } function renderSelectedSeriesDetails() { const item = state.selectedSeries; if (!item) { el.seriesDetails.classList.add("hidden"); return; } const raw = item.raw || {}; const summary = state.selectedSeriesSummary || {}; const imageUrl = (summary.banner_url || summary.poster_url || raw.image_url || "").toString().trim(); const overview = (summary.overview || raw.overview || "").toString().trim(); const tvdbUrl = buildTvdbUrl(item); if (imageUrl) { el.seriesPoster.src = imageUrl; el.seriesPoster.classList.remove("hidden"); } else { el.seriesPoster.removeAttribute("src"); el.seriesPoster.classList.add("hidden"); } el.seriesFirstAired.textContent = fallbackText(summary.first_aired || raw.first_air_time); el.seriesNetwork.textContent = fallbackText(summary.network || raw.network); el.seriesStatus.textContent = fallbackText(summary.status || raw.status); el.seriesOverview.textContent = overview || "No overview available."; if (tvdbUrl) { el.seriesTvdbLink.href = tvdbUrl; el.seriesTvdbLink.classList.remove("hidden"); } else { el.seriesTvdbLink.removeAttribute("href"); el.seriesTvdbLink.classList.add("hidden"); } el.seriesDetails.classList.remove("hidden"); } async function loadSeriesSummary(seriesId) { const data = await api(`/api/tvdb/series/${encodeURIComponent(seriesId)}/summary`); state.selectedSeriesSummary = data || null; } 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); const compactText = compactEpisodeText(item.episode || {}); left.appendChild(document.createTextNode(compactText)); left.title = (item.episode && (item.episode.label || compactText)) || compactText; 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); const fullPath = (item.file && (item.file.path || item.file.name)) || "(file)"; const fileName = basename(fullPath) || "(file)"; left.appendChild(document.createTextNode(fileName)); left.title = `Name: ${fileName}\nPath: ${fullPath}`; 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; state.selectedSeriesSummary = null; el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`; renderSelectedSeriesDetails(); try { await loadSeriesSummary(item.id); renderSelectedSeriesDetails(); } catch (_err) { // Keep UI responsive with search payload fallback if summary lookup fails. } 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 = ""; let previousSeasonKey = null; state.episodes.forEach((episode) => { const seasonNum = Number(episode.season_number); const seasonKey = Number.isFinite(seasonNum) ? String(seasonNum) : "unknown"; if (previousSeasonKey === null || seasonKey !== previousSeasonKey) { const seasonHeader = document.createElement("li"); seasonHeader.className = "season-header"; if (seasonNum === 0) { seasonHeader.textContent = "Specials"; } else if (Number.isFinite(seasonNum)) { seasonHeader.textContent = `Season ${seasonNum}`; } else { seasonHeader.textContent = "Season Unknown"; } el.episodesList.appendChild(seasonHeader); previousSeasonKey = seasonKey; } 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.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; renderSelectedSeriesDetails(); bindEvents(); await loadSelectedEpisodes(); await loadSelectedFiles(); await loadRoots(); } init().catch((err) => out("Init error", { detail: err.message || String(err) })); })();