Files
rename-mvp/app/static/app.js
T
2026-03-09 13:41:04 +01:00

721 lines
28 KiB
JavaScript

(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,
settings: {
set_file_date_to_first_aired_date: false,
default_media_root_path: null,
},
};
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"),
settingsBtn: document.getElementById("settingsBtn"),
settingsModal: document.getElementById("settingsModal"),
closeSettingsModalBtn: document.getElementById("closeSettingsModalBtn"),
saveSettingsBtn: document.getElementById("saveSettingsBtn"),
setFileDateToFirstAiredDateInput: document.getElementById("setFileDateToFirstAiredDateInput"),
defaultMediaRootSelect: document.getElementById("defaultMediaRootSelect"),
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: <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.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,
};
applySettingsToForm();
}
async function saveSettings() {
const payload = {
set_file_date_to_first_aired_date: !!el.setFileDateToFirstAiredDateInput.checked,
default_media_root_path: (el.defaultMediaRootSelect.value || "").trim() || null,
};
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);
}
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();
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.settingsBtn.addEventListener("click", openSettingsModal);
el.closeSettingsModalBtn.addEventListener("click", closeSettingsModal);
el.saveSettingsBtn.addEventListener("click", () => withHandler(saveSettings, el.saveSettingsBtn));
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.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 loadSettings();
await loadSelectedEpisodes();
await loadSelectedFiles();
await loadRoots();
}
init().catch((err) => out("Init error", { detail: err.message || String(err) }));
})();