954 lines
36 KiB
JavaScript
954 lines
36 KiB
JavaScript
(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: [],
|
||
};
|
||
|
||
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"),
|
||
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 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.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.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) }));
|
||
})();
|