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)}`}
${title.seasons.map(renderSeason).join("")}
`;
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();
});
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";
}
});
}
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)}
`;
});