const state = { titles: [], activeId: "", query: "", view: "collection" }; const els = { libraryBadge: document.querySelector("#libraryBadge"), libraryPath: document.querySelector("#libraryPath"), backBtn: document.querySelector("#backBtn"), rescanBtn: document.querySelector("#rescanBtn"), searchInput: document.querySelector("#searchInput"), details: document.querySelector("#details"), titleCardTemplate: document.querySelector("#titleCardTemplate") }; function bytes(value) { const units = ["B", "KB", "MB", "GB", "TB"]; let size = value; let unit = 0; while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; } return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`; } async function api(path, options = {}) { const response = await fetch(path, { headers: { "content-type": "application/json" }, ...options }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Request failed"); return data; } async function loadLibrary() { const data = await api("/api/library"); state.titles = data.titles; els.libraryPath.textContent = data.libraryDir; els.libraryBadge.textContent = `${state.titles.length} title${state.titles.length === 1 ? "" : "s"}`; if (!state.titles.some((title) => title.id === state.activeId)) { state.activeId = ""; state.view = "collection"; } render(); } function filteredTitles() { const query = state.query.toLowerCase(); if (!query) return state.titles; return state.titles.filter((title) => { return [ title.title, title.folder, title.description, ...title.videos.map((video) => video.relativePath) ].join(" ").toLowerCase().includes(query); }); } function render() { const activeTitle = state.titles.find((item) => item.id === state.activeId); els.backBtn.classList.toggle("hidden", state.view !== "details"); if (state.view === "details" && activeTitle) { renderDetails(activeTitle); return; } renderCollection(); } function renderCollection() { const titles = filteredTitles(); if (!titles.length) { els.details.innerHTML = `

No titles found

Expected: Library/<Movie or Series>/filename.mp4 or Library/<Series>/Season 01/filename.mp4

`; return; } els.details.innerHTML = `
`; const grid = document.querySelector("#collectionGrid"); for (const title of titles) { const node = els.titleCardTemplate.content.firstElementChild.cloneNode(true); node.querySelector(".title-card__name").textContent = title.title; node.querySelector(".title-card__meta").textContent = `${title.count} file${title.count === 1 ? "" : "s"} · ${bytes(title.size)}`; node.querySelector(".title-card__fallback").textContent = initials(title.title); const poster = node.querySelector(".title-card__poster"); const image = node.querySelector("img"); if (title.thumbnail) { poster.classList.add("has-image"); image.src = title.thumbnail; image.alt = ""; } node.addEventListener("click", () => { state.activeId = title.id; state.view = "details"; render(); }); grid.append(node); } } function renderDetails(title) { els.details.innerHTML = `
${title.thumbnail ? `` : `${initials(title.title)}`}

${escapeHtml(title.title)}

${escapeHtml(title.folder)} · ${title.count} file${title.count === 1 ? "" : "s"} · ${bytes(title.size)}

Description

${title.description ? escapeHtml(title.description) : "No description yet"}

${title.seasons.map(renderSeason).join("")}
`; document.querySelector("#editMetaBtn").addEventListener("click", () => { renderMetadataForm(title); }); document.querySelector("#makeThumbBtn").addEventListener("click", async (event) => { event.currentTarget.disabled = true; event.currentTarget.textContent = "Working"; try { await api(`/api/titles/${encodeURIComponent(title.id)}/thumbnail`, { method: "POST", body: "{}" }); await loadLibrary(); } catch (err) { alert(err.message); event.currentTarget.disabled = false; event.currentTarget.textContent = "Thumbnail"; } }); document.querySelector("#fetchMetadataBtn").addEventListener("click", async (event) => { event.currentTarget.disabled = true; event.currentTarget.textContent = "Fetching"; try { await api(`/api/titles/${encodeURIComponent(title.id)}/metadata`, { method: "POST", body: "{}" }); await loadLibrary(); } catch (err) { alert(err.message); event.currentTarget.disabled = false; event.currentTarget.textContent = "Metadata"; } }); } function renderMetadataForm(title) { document.querySelector("#metaView").classList.add("hidden"); const mount = document.querySelector("#metaEditMount"); mount.innerHTML = `
`; document.querySelector("#cancelMetaBtn").addEventListener("click", () => { mount.innerHTML = ""; document.querySelector("#metaView").classList.remove("hidden"); }); document.querySelector("#metaForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); await api(`/api/titles/${encodeURIComponent(title.id)}`, { method: "PATCH", body: JSON.stringify({ title: form.get("title"), thumbnail: form.get("thumbnail"), description: form.get("description") }) }); await loadLibrary(); }); } function renderSeason(season) { return `

${escapeHtml(season.name)}

${season.videos.map((video) => `
${escapeHtml(video.name)} ${escapeHtml(video.relativePath)} · ${bytes(video.size)}
`).join("")}
`; } function initials(value) { return value.split(/\s+/).slice(0, 2).map((part) => part[0] || "").join("").toUpperCase(); } function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); } function escapeAttr(value) { return escapeHtml(value).replace(/`/g, "`"); } els.searchInput.addEventListener("input", (event) => { state.query = event.target.value; state.view = "collection"; render(); }); els.backBtn.addEventListener("click", () => { state.view = "collection"; render(); }); els.rescanBtn.addEventListener("click", async () => { els.rescanBtn.disabled = true; els.rescanBtn.textContent = "Scanning"; try { await api("/api/rescan", { method: "POST", body: "{}" }); await loadLibrary(); } finally { els.rescanBtn.disabled = false; els.rescanBtn.textContent = "Rescan"; } }); loadLibrary().catch((err) => { els.details.innerHTML = `

Could not load library

${escapeHtml(err.message)}

`; });