(function () { const STORAGE_KEY = "rename_mvp_session_id"; const THEME_STORAGE_KEY = "rename_mvp_theme"; const state = { sessionId: initSessionId(), selectedSeries: null, selectedSeriesSummary: null, episodes: [], selectedEpisodes: [], selectedFiles: [], selectedPairIndex: null, roots: [], modalFolders: [], modalFiles: [], modalSelectedFilePaths: new Set(), modalKnownFiles: {}, modalFileFilter: "", modalVisibleFiles: [], syncScrolling: false, settings: { set_file_date_to_first_aired_date: false, default_media_root_path: null, remember_max_series: 10, }, rememberedSeries: [], liveSearchItems: [], tvdbModalUrl: "", tvdbModalTimer: null, }; const el = { sessionMeta: document.getElementById("sessionMeta"), outputBox: document.getElementById("outputBox"), themeToggleBtn: document.getElementById("themeToggleBtn"), searchInput: document.getElementById("searchInput"), searchDropdownBtn: document.getElementById("searchDropdownBtn"), searchCombobox: document.getElementById("searchCombobox"), searchComboboxDropdown: document.getElementById("searchComboboxDropdown"), rememberedDropdownList: document.getElementById("rememberedDropdownList"), 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"), tvdbModal: document.getElementById("tvdbModal"), closeTvdbModalBtn: document.getElementById("closeTvdbModalBtn"), tvdbModalOpenInTabBtn: document.getElementById("tvdbModalOpenInTabBtn"), tvdbModalFrame: document.getElementById("tvdbModalFrame"), tvdbModalFallback: document.getElementById("tvdbModalFallback"), settingsBtn: document.getElementById("settingsBtn"), settingsModal: document.getElementById("settingsModal"), closeSettingsModalBtn: document.getElementById("closeSettingsModalBtn"), saveSettingsBtn: document.getElementById("saveSettingsBtn"), setFileDateToFirstAiredDateInput: document.getElementById("setFileDateToFirstAiredDateInput"), defaultMediaRootSelect: document.getElementById("defaultMediaRootSelect"), rememberMaxSeriesInput: document.getElementById("rememberMaxSeriesInput"), purgeRememberedSeriesBtn: document.getElementById("purgeRememberedSeriesBtn"), 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"), modalRecursiveInput: document.getElementById("modalRecursiveInput"), modalFileFilterInput: document.getElementById("modalFileFilterInput"), modalSelectAllFilesBtn: document.getElementById("modalSelectAllFilesBtn"), modalClearSelectionBtn: document.getElementById("modalClearSelectionBtn"), modalFoldersList: document.getElementById("modalFoldersList"), modalFilesList: document.getElementById("modalFilesList"), modalSelectionCount: document.getElementById("modalSelectionCount"), 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 getStoredTheme() { const stored = localStorage.getItem(THEME_STORAGE_KEY); if (stored === "light" || stored === "dark") return stored; return "dark"; } function applyTheme(theme) { const normalized = theme === "light" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", normalized); localStorage.setItem(THEME_STORAGE_KEY, normalized); if (el.themeToggleBtn) { const isDark = normalized === "dark"; // Icon represents switch action: dark -> light (sun), light -> dark (moon). el.themeToggleBtn.textContent = isDark ? "☀️" : "🌙"; el.themeToggleBtn.setAttribute( "aria-label", isDark ? "Switch to light theme" : "Switch to dark theme" ); el.themeToggleBtn.title = isDark ? "Switch to light theme" : "Switch to dark theme"; } } function toggleTheme() { const current = document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; applyTheme(current === "dark" ? "light" : "dark"); } 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 validTvdbUrl(value) { try { const url = new URL((value || "").toString().trim()); if (url.protocol !== "https:") return false; return url.hostname === "www.thetvdb.com" || url.hostname === "thetvdb.com"; } catch (_err) { return false; } } function clearTvdbModalTimer() { if (state.tvdbModalTimer) { window.clearTimeout(state.tvdbModalTimer); state.tvdbModalTimer = null; } } function showTvdbModalFallback(message) { const fallbackText = el.tvdbModalFallback.querySelector("p"); if (fallbackText) fallbackText.textContent = message; el.tvdbModalFallback.classList.remove("hidden"); } function openTvdbModal() { if (!validTvdbUrl(state.tvdbModalUrl)) return; el.tvdbModal.classList.remove("hidden"); el.tvdbModal.setAttribute("aria-hidden", "false"); el.tvdbModalFallback.classList.add("hidden"); const fallbackText = el.tvdbModalFallback.querySelector("p"); if (fallbackText) { fallbackText.textContent = "This page cannot be embedded here. TheTVDB may block framing using browser security headers."; } clearTvdbModalTimer(); state.tvdbModalTimer = window.setTimeout(() => { showTvdbModalFallback("No embed load confirmation received. Embedding is likely blocked."); }, 5000); el.tvdbModalFrame.src = state.tvdbModalUrl; } function closeTvdbModal() { clearTvdbModalTimer(); el.tvdbModal.classList.add("hidden"); el.tvdbModal.setAttribute("aria-hidden", "true"); el.tvdbModalFrame.removeAttribute("src"); el.tvdbModalFallback.classList.add("hidden"); } function openTvdbInTab() { if (!validTvdbUrl(state.tvdbModalUrl)) return; window.open(state.tvdbModalUrl, "_blank", "noopener,noreferrer"); } 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) { state.tvdbModalUrl = tvdbUrl; el.seriesTvdbLink.href = tvdbUrl; el.seriesTvdbLink.classList.remove("hidden"); } else { state.tvdbModalUrl = ""; 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 applySettingsToForm() { el.setFileDateToFirstAiredDateInput.checked = !!state.settings.set_file_date_to_first_aired_date; if (el.rememberMaxSeriesInput) { el.rememberMaxSeriesInput.value = String(state.settings.remember_max_series || 10); } if (el.defaultMediaRootSelect) { const wanted = state.settings.default_media_root_path || ""; el.defaultMediaRootSelect.value = wanted; if (el.defaultMediaRootSelect.value !== wanted) { el.defaultMediaRootSelect.value = ""; } } } function populateDefaultRootOptions() { if (!el.defaultMediaRootSelect) return; const selectedValue = state.settings.default_media_root_path || ""; el.defaultMediaRootSelect.innerHTML = ""; const auto = document.createElement("option"); auto.value = ""; auto.textContent = "Auto (first available)"; el.defaultMediaRootSelect.appendChild(auto); state.roots.forEach((root) => { const opt = document.createElement("option"); opt.value = root.path || ""; opt.textContent = `${root.id}: ${root.path}`; el.defaultMediaRootSelect.appendChild(opt); }); el.defaultMediaRootSelect.value = selectedValue; if (el.defaultMediaRootSelect.value !== selectedValue) { el.defaultMediaRootSelect.value = ""; } } function openSettingsModal() { applySettingsToForm(); el.settingsModal.classList.remove("hidden"); el.settingsModal.setAttribute("aria-hidden", "false"); } function closeSettingsModal() { el.settingsModal.classList.add("hidden"); el.settingsModal.setAttribute("aria-hidden", "true"); } async function loadSettings() { const data = await api("/api/session/settings"); state.settings = data.settings || { set_file_date_to_first_aired_date: false, default_media_root_path: null, remember_max_series: 10, }; applySettingsToForm(); } async function saveSettings() { const rememberMax = Number(el.rememberMaxSeriesInput.value || "10"); if (!Number.isInteger(rememberMax) || rememberMax < 1 || rememberMax > 100) { throw new Error("Remember max series must be an integer between 1 and 100"); } const payload = { set_file_date_to_first_aired_date: !!el.setFileDateToFirstAiredDateInput.checked, default_media_root_path: (el.defaultMediaRootSelect.value || "").trim() || null, remember_max_series: rememberMax, }; const data = await api("/api/session/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); state.settings = data.settings || payload; applySettingsToForm(); await loadRoots(); closeSettingsModal(); out("Settings saved", data); } async function loadRememberedSeries() { const data = await api("/api/session/remembered-series"); state.rememberedSeries = data.items || []; } async function rememberSeries(item) { await api("/api/session/remembered-series", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ item }), }); await loadRememberedSeries(); } async function purgeRememberedSeries() { const ok = window.confirm("Are you sure?"); if (!ok) return; await api("/api/session/remembered-series", { method: "DELETE" }); await loadRememberedSeries(); renderRememberedDropdown(); } async function removeRememberedSeriesItem(seriesId) { const normalizedId = String(seriesId || "").trim(); if (!normalizedId) return; const data = await api(`/api/session/remembered-series/${encodeURIComponent(normalizedId)}`, { method: "DELETE", }); state.rememberedSeries = data.items || []; renderRememberedDropdown(); openRememberedDropdown(); } function rememberedForQuery(query) { const normalized = (query || "").trim().toLowerCase(); const rememberedItems = (state.rememberedSeries || []).map((entry) => entry.series || {}); return rememberedItems.filter((item) => { if (!normalized) return true; const text = `${item.display_name || ""} ${item.name || ""}`.toLowerCase(); return text.includes(normalized); }); } function openRememberedDropdown() { renderRememberedDropdown(); el.searchComboboxDropdown.classList.remove("hidden"); } function closeRememberedDropdown() { el.searchComboboxDropdown.classList.add("hidden"); } function toggleRememberedDropdown() { if (el.searchComboboxDropdown.classList.contains("hidden")) { openRememberedDropdown(); return; } closeRememberedDropdown(); } function renderRememberedDropdown() { const query = (el.searchInput.value || "").trim(); const items = rememberedForQuery(query).slice().sort((a, b) => { const aLabel = (a.display_name || a.name || "").toLowerCase(); const bLabel = (b.display_name || b.name || "").toLowerCase(); return aLabel.localeCompare(bLabel); }); el.rememberedDropdownList.innerHTML = ""; items.forEach((item) => { const li = document.createElement("li"); const left = document.createElement("span"); left.className = "remembered-item-title"; const label = item.display_name || item.name || "(series)"; left.textContent = label; left.title = label; li.appendChild(left); const removeBtn = document.createElement("button"); removeBtn.type = "button"; removeBtn.className = "remembered-remove-btn"; removeBtn.textContent = "×"; removeBtn.setAttribute("aria-label", `Remove ${label}`); removeBtn.title = `Remove ${label}`; removeBtn.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); withHandler(() => removeRememberedSeriesItem(item.id), removeBtn); }); li.appendChild(removeBtn); left.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); withHandler(() => selectSeries(item), left); }); el.rememberedDropdownList.appendChild(li); }); } function renderSearchResults(items) { el.searchResults.innerHTML = ""; (items || []).forEach((item) => { const li = document.createElement("li"); const left = document.createElement("span"); left.textContent = item.display_name || item.name || "(series)"; li.appendChild(left); li.addEventListener("click", () => withHandler(() => selectSeries(item), li)); el.searchResults.appendChild(li); }); } async function selectSeries(item) { closeRememberedDropdown(); state.selectedSeries = item; state.selectedSeriesSummary = null; el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`; renderSelectedSeriesDetails(); await rememberSeries(item); renderRememberedDropdown(); try { await loadSeriesSummary(item.id); renderSelectedSeriesDetails(); } catch (_err) { // Keep UI responsive with fallback data. } await loadEpisodes(); } function preferredRootId() { if (!state.roots.length) return ""; const wantedPath = (state.settings.default_media_root_path || "").trim(); if (wantedPath) { const match = state.roots.find((root) => (root.path || "") === wantedPath); if (match) return match.id; } return state.roots[0].id; } 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; li.appendChild(left); 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}`; li.appendChild(left); 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); }); populateDefaultRootOptions(); if (state.roots.length) { el.modalRootSelect.value = preferredRootId(); await loadModalFolders(); } } async function doSearch() { const query = (el.searchInput.value || "").trim(); if (!query || query.length < 2) { state.liveSearchItems = []; el.searchResults.innerHTML = ""; return; } const data = await api(`/api/tvdb/search?q=${encodeURIComponent(query)}`); state.liveSearchItems = data.items || []; renderSearchResults(state.liveSearchItems); closeRememberedDropdown(); 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() { const existingPaths = (state.selectedFiles || []) .map((item) => (item.file && item.file.path) || "") .filter((p) => typeof p === "string" && p.trim().length > 0); state.modalSelectedFilePaths = new Set(existingPaths); state.modalKnownFiles = {}; (state.selectedFiles || []).forEach((item) => { const path = (item.file && item.file.path) || ""; if (!path) return; state.modalKnownFiles[path] = { path, name: (item.file && item.file.name) || basename(path), }; }); state.modalFileFilter = ""; el.fileModalTitle.textContent = "File Discovery"; el.modalAddSelectedFilesBtn.style.display = ""; if (el.modalFileFilterInput) { el.modalFileFilterInput.value = ""; } el.fileModal.classList.remove("hidden"); el.fileModal.setAttribute("aria-hidden", "false"); updateModalSelectionCount(); } 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 = ""; const data = await api( `/api/files/folders?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(subpath)}&limit=5000` ); state.modalFolders = data.items || []; el.modalFoldersList.innerHTML = ""; state.modalFolders.forEach((folder) => { const li = document.createElement("li"); const left = document.createElement("span"); left.textContent = folder.subpath || folder.name; li.appendChild(left); li.addEventListener("click", () => withHandler(() => loadModalFiles(folder.subpath || ""), li)); el.modalFoldersList.appendChild(li); }); out("Folders loaded", data); } function updateModalSelectionCount() { if (!el.modalSelectionCount) return; const count = state.modalSelectedFilePaths.size; const visibleCount = (state.modalVisibleFiles || []).length; el.modalSelectionCount.textContent = `${count} selected (${visibleCount} visible)`; if (el.modalAddSelectedFilesBtn) { el.modalAddSelectedFilesBtn.disabled = count === 0; } } function renderModalFiles() { const filter = (state.modalFileFilter || "").toLowerCase(); const visible = (state.modalFiles || []).filter((file) => { const text = `${file.relative_path || ""} ${file.name || ""}`.toLowerCase(); return !filter || text.includes(filter); }); state.modalVisibleFiles = visible; el.modalFilesList.innerHTML = ""; visible.forEach((file) => { const li = document.createElement("li"); const left = document.createElement("span"); left.textContent = file.relative_path || file.name; li.appendChild(left); const isSelected = state.modalSelectedFilePaths.has(file.path); if (isSelected) li.classList.add("selected"); li.addEventListener("click", () => { 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"); } updateModalSelectionCount(); }); el.modalFilesList.appendChild(li); }); updateModalSelectionCount(); } async function loadModalFiles(subpath) { const rootId = el.modalRootSelect.value; const chosenSubpath = (subpath || "").trim(); if (!rootId) throw new Error("No root selected"); if (!chosenSubpath) throw new Error("Choose a folder first"); const recursive = el.modalRecursiveInput.checked ? "true" : "false"; const data = await api( `/api/files/discover?root_id=${encodeURIComponent(rootId)}&subpath=${encodeURIComponent(chosenSubpath)}&recursive=${recursive}&limit=200` ); state.modalFiles = data.items || []; state.modalFiles.forEach((file) => { const path = file.path || ""; if (!path) return; state.modalKnownFiles[path] = { path, name: file.name || basename(path), }; }); renderModalFiles(); out("Discovered files", data); } async function addModalSelectedFiles() { const payload = Array.from(state.modalSelectedFilePaths) .filter((path) => typeof path === "string" && path.trim().length > 0) .map((path) => { const known = state.modalKnownFiles[path] || {}; return { path, name: known.name || basename(path), }; }); await api(q("/api/session/selected-files"), { method: "DELETE" }); if (payload.length) { await api(q("/api/session/selected-files"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: payload }), }); } await loadSelectedFiles(); closeFileModal(); } function selectAllVisibleModalFiles() { (state.modalVisibleFiles || []).forEach((file) => { state.modalSelectedFilePaths.add(file.path); }); renderModalFiles(); } function clearModalSelection() { state.modalSelectedFilePaths.clear(); renderModalFiles(); } 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() { if (el.themeToggleBtn) { el.themeToggleBtn.addEventListener("click", toggleTheme); } el.searchBtn.addEventListener("click", () => withHandler(doSearch, el.searchBtn)); el.searchInput.addEventListener("focus", openRememberedDropdown); el.searchInput.addEventListener("click", openRememberedDropdown); el.searchInput.addEventListener("input", openRememberedDropdown); el.searchDropdownBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleRememberedDropdown(); }); el.searchCombobox.addEventListener("click", (e) => { e.stopPropagation(); }); document.addEventListener("click", closeRememberedDropdown); el.seriesTvdbLink.addEventListener("click", (e) => { e.preventDefault(); openTvdbModal(); }); el.closeTvdbModalBtn.addEventListener("click", closeTvdbModal); el.tvdbModalOpenInTabBtn.addEventListener("click", openTvdbInTab); el.tvdbModal.addEventListener("click", (e) => { if (e.target === el.tvdbModal) closeTvdbModal(); }); el.tvdbModalFrame.addEventListener("load", () => { clearTvdbModalTimer(); el.tvdbModalFallback.classList.add("hidden"); }); el.settingsBtn.addEventListener("click", openSettingsModal); el.closeSettingsModalBtn.addEventListener("click", closeSettingsModal); el.saveSettingsBtn.addEventListener("click", () => withHandler(saveSettings, el.saveSettingsBtn)); el.purgeRememberedSeriesBtn.addEventListener("click", () => withHandler(purgeRememberedSeries, el.purgeRememberedSeriesBtn)); el.settingsModal.addEventListener("click", (e) => { if (e.target === el.settingsModal) closeSettingsModal(); }); 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.modalFileFilterInput.addEventListener("input", () => { state.modalFileFilter = el.modalFileFilterInput.value || ""; renderModalFiles(); }); el.modalSelectAllFilesBtn.addEventListener("click", selectAllVisibleModalFiles); el.modalClearSelectionBtn.addEventListener("click", clearModalSelection); 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() { applyTheme(getStoredTheme()); el.sessionMeta.textContent = `session_id: ${state.sessionId}`; el.modalRecursiveInput.checked = true; renderSelectedSeriesDetails(); bindEvents(); await loadSettings(); await loadRememberedSeries(); renderRememberedDropdown(); closeRememberedDropdown(); await loadSelectedEpisodes(); await loadSelectedFiles(); await loadRoots(); } init().catch((err) => out("Init error", { detail: err.message || String(err) })); })();