Files
rename-mvp/app/static/app.js
T

1203 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const STORAGE_KEY = "rename_mvp_session_id";
const THEME_STORAGE_KEY = "rename_mvp_theme";
const state = {
sessionId: initSessionId(),
selectedSeries: null,
selectedSeriesSummary: null,
episodes: [],
selectedEpisodeIds: new Set(),
episodeSelectionAnchorId: null,
selectedEpisodes: [],
selectedFiles: [],
selectedPairIndex: null,
roots: [],
modalFolders: [],
modalFiles: [],
modalSelectedFilePaths: new Set(),
modalKnownFiles: {},
modalFileFilter: "",
modalVisibleFiles: [],
modalSelectionAnchorPath: null,
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"),
addSelectedEpisodesBtn: document.getElementById("addSelectedEpisodesBtn"),
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 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 parseIsoDateOnly(value) {
const text = (value || "").toString().trim();
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
const dt = new Date(year, month - 1, day);
if (Number.isNaN(dt.getTime())) return null;
return dt;
}
function formatDateDdMmYyyy(value) {
const dt = parseIsoDateOnly(value);
if (!dt) return "";
const day = String(dt.getDate()).padStart(2, "0");
const month = String(dt.getMonth() + 1).padStart(2, "0");
const year = dt.getFullYear();
return `${day}-${month}-${year}`;
}
function isFutureAiredDate(value) {
const aired = parseIsoDateOnly(value);
if (!aired) return false;
const today = new Date();
const todayOnly = new Date(today.getFullYear(), today.getMonth(), today.getDate());
return aired.getTime() > todayOnly.getTime();
}
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: <b>${epCount}</b>`;
el.fileMeta.innerHTML = `Rows: <b>${fileCount}</b> <span class="${mismatch ? "mismatch" : ""}">${mismatchText}</span>`;
}
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);
}
function episodeKey(episode) {
const id = (episode && episode.id != null) ? String(episode.id).trim() : "";
if (id) return id;
return `${episode.season_number || ""}-${episode.episode_number || ""}-${episode.title || ""}`;
}
function updateAddSelectedEpisodesControl() {
if (!el.addSelectedEpisodesBtn) return;
el.addSelectedEpisodesBtn.disabled = state.selectedEpisodeIds.size === 0;
}
function clearEpisodeSelection() {
state.selectedEpisodeIds = new Set();
state.episodeSelectionAnchorId = null;
renderEpisodesList();
}
function renderEpisodesList() {
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 key = episodeKey(episode);
const li = document.createElement("li");
li.classList.add("episode-row");
if (state.selectedEpisodeIds.has(key)) li.classList.add("selected");
if (state.episodeSelectionAnchorId && state.episodeSelectionAnchorId === key) {
li.classList.add("episode-anchor");
}
const left = document.createElement("span");
left.className = "episode-main";
const title = document.createElement("div");
title.className = "episode-title";
title.textContent = compactEpisodeText(episode);
left.appendChild(title);
const formattedDate = formatDateDdMmYyyy(episode.aired);
if (formattedDate) {
const dateLine = document.createElement("div");
const future = isFutureAiredDate(episode.aired);
dateLine.className = future ? "episode-date episode-date-future" : "episode-date";
dateLine.textContent = `${future ? "Airing" : "Aired"} ${formattedDate}`;
left.appendChild(dateLine);
}
li.appendChild(left);
li.addEventListener("click", (event) => {
handleEpisodeRowClick(key, event);
});
el.episodesList.appendChild(li);
});
updateAddSelectedEpisodesControl();
}
function handleEpisodeRowClick(clickedId, event) {
const clicked = String(clickedId || "").trim();
if (!clicked) return;
const visibleEpisodeIds = (state.episodes || []).map((ep) => episodeKey(ep));
const isShift = !!(event && event.shiftKey);
const isToggle = !!(event && (event.ctrlKey || event.metaKey));
if (isShift) {
const anchor = String(state.episodeSelectionAnchorId || "").trim();
const from = visibleEpisodeIds.indexOf(anchor);
const to = visibleEpisodeIds.indexOf(clicked);
if (from >= 0 && to >= 0) {
const start = Math.min(from, to);
const end = Math.max(from, to);
state.selectedEpisodeIds = new Set(visibleEpisodeIds.slice(start, end + 1));
} else {
state.selectedEpisodeIds = new Set([clicked]);
}
state.episodeSelectionAnchorId = clicked;
renderEpisodesList();
return;
}
if (isToggle) {
if (state.selectedEpisodeIds.has(clicked)) {
state.selectedEpisodeIds.delete(clicked);
} else {
state.selectedEpisodeIds.add(clicked);
}
state.episodeSelectionAnchorId = clicked;
renderEpisodesList();
return;
}
state.selectedEpisodeIds = new Set([clicked]);
state.episodeSelectionAnchorId = clicked;
renderEpisodesList();
}
async function addSelectedEpisodes() {
const selectedIds = new Set(state.selectedEpisodeIds);
if (!selectedIds.size) return;
if (!state.selectedSeries || !state.selectedSeries.name) {
throw new Error("Select a series first");
}
const items = (state.episodes || [])
.filter((episode) => selectedIds.has(episodeKey(episode)))
.map((episode) => ({
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,
}));
if (!items.length) return;
await api(q("/api/session/selected-episodes"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items }),
});
clearEpisodeSelection();
await loadSelectedEpisodes();
}
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 || [];
state.selectedEpisodeIds = new Set();
state.episodeSelectionAnchorId = null;
renderEpisodesList();
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 = "";
state.modalSelectionAnchorPath = null;
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");
if (state.modalSelectionAnchorPath && state.modalSelectionAnchorPath === file.path) {
li.classList.add("modal-anchor");
}
li.addEventListener("click", (event) => {
handleModalFileRowClick(file.path, event);
});
el.modalFilesList.appendChild(li);
});
updateModalSelectionCount();
}
function handleModalFileRowClick(path, event) {
const clickedPath = (path || "").toString().trim();
if (!clickedPath) return;
const visiblePaths = (state.modalVisibleFiles || [])
.map((f) => (f && f.path ? f.path : ""))
.filter((p) => p);
const isShift = !!(event && event.shiftKey);
const isToggle = !!(event && (event.ctrlKey || event.metaKey));
if (isShift) {
const anchor = state.modalSelectionAnchorPath || "";
const from = visiblePaths.indexOf(anchor);
const to = visiblePaths.indexOf(clickedPath);
if (from >= 0 && to >= 0) {
const start = Math.min(from, to);
const end = Math.max(from, to);
const rangePaths = visiblePaths.slice(start, end + 1);
state.modalSelectedFilePaths = new Set(rangePaths);
} else {
// Fallback: no valid anchor in current visible list.
state.modalSelectedFilePaths = new Set([clickedPath]);
}
state.modalSelectionAnchorPath = clickedPath;
renderModalFiles();
return;
}
if (isToggle) {
if (state.modalSelectedFilePaths.has(clickedPath)) {
state.modalSelectedFilePaths.delete(clickedPath);
} else {
state.modalSelectedFilePaths.add(clickedPath);
}
state.modalSelectionAnchorPath = clickedPath;
renderModalFiles();
return;
}
state.modalSelectedFilePaths = new Set([clickedPath]);
state.modalSelectionAnchorPath = clickedPath;
renderModalFiles();
}
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.modalSelectionAnchorPath = null;
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();
state.modalSelectionAnchorPath = null;
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.addSelectedEpisodesBtn.addEventListener("click", () =>
withHandler(addSelectedEpisodes, el.addSelectedEpisodesBtn)
);
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) }));
})();