const state = { titles: [], activeId: "", query: "", view: "collection" }; const els = { libraryBadge: document.querySelector("#libraryBadge"), backBtn: document.querySelector("#backBtn"), rescanBtn: document.querySelector("#rescanBtn"), searchInput: document.querySelector("#searchInput"), details: document.querySelector("#details"), titleCardTemplate: document.querySelector("#titleCardTemplate"), playerOverlay: document.querySelector("#playerOverlay"), playerVideo: document.querySelector("#playerVideo"), closePlayerBtn: document.querySelector("#closePlayerBtn"), playerNowPlaying: document.querySelector("#playerNowPlaying") }; const player = { playlist: [], index: 0 }; 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.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("")}
`; wireEpisodeCards(title); document.querySelector("#editMetaBtn").addEventListener("click", () => { renderMetadataForm(title); }); document.querySelector("#playAllBtn").addEventListener("click", () => { startPlaylist(flattenVideos(title), 0); }); 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) => ` `).join("")}
`; } function flattenVideos(title) { return title.seasons.flatMap((season) => season.videos); } function wireEpisodeCards(title) { const videos = flattenVideos(title); document.querySelectorAll("[data-video-id]").forEach((node) => { node.addEventListener("click", () => { const index = videos.findIndex((video) => video.id === node.dataset.videoId); startPlaylist(videos, Math.max(index, 0)); }); }); } function startPlaylist(videos, index = 0) { if (!videos.length) return; player.playlist = videos; player.index = index; els.playerOverlay.classList.remove("hidden"); playCurrent(); const fullscreenTarget = els.playerOverlay; if (fullscreenTarget.requestFullscreen) { fullscreenTarget.requestFullscreen().catch(() => {}); } } function playCurrent() { const video = player.playlist[player.index]; if (!video) { closePlayer(); return; } els.playerVideo.src = `/video/${encodeURIComponent(video.id)}`; els.playerNowPlaying.textContent = `${video.name} · ${player.index + 1} / ${player.playlist.length}`; els.playerVideo.play().catch(() => {}); } function closePlayer() { els.playerVideo.pause(); els.playerVideo.removeAttribute("src"); els.playerVideo.load(); els.playerOverlay.classList.add("hidden"); if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); } 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.closePlayerBtn.addEventListener("click", closePlayer); els.playerVideo.addEventListener("ended", () => { player.index += 1; playCurrent(); }); document.addEventListener("fullscreenchange", () => { if (!document.fullscreenElement && !els.playerOverlay.classList.contains("hidden")) { closePlayer(); } }); 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)}

`; });