Files
2026-07-17 10:00:37 +01:00

1035 lines
31 KiB
JavaScript

const STORAGE_KEY = "media-tracker-items-v1";
const mediaTypes = {
movie: {
label: "Movies",
singular: "Movie",
badge: "movie",
progressLabels: {
planned: "No watch date",
active: "Started watching",
complete: "Watched",
},
},
tv: {
label: "TV Shows",
singular: "TV Show",
badge: "tv",
progressLabels: {
planned: "No dates",
active: "Watching",
complete: "Finished",
},
},
book: {
label: "Books",
singular: "Book",
badge: "book",
progressLabels: {
planned: "No read date",
active: "Reading",
complete: "Read",
},
},
game: {
label: "Games",
singular: "Game",
badge: "game",
progressLabels: {
planned: "No play date",
active: "Playing",
complete: "Completed",
},
},
};
const state = {
activeType: "movie",
results: [],
collection: loadCollection(),
filters: {
type: "all",
progress: "all",
search: "",
sort: "recent",
},
};
const elements = {
typeTabs: document.querySelectorAll(".type-tab"),
searchForm: document.getElementById("searchForm"),
searchInput: document.getElementById("searchInput"),
searchFeedback: document.getElementById("searchFeedback"),
results: document.getElementById("results"),
toggleManualButton: document.getElementById("toggleManualButton"),
manualForm: document.getElementById("manualForm"),
manualTitle: document.getElementById("manualTitle"),
manualYear: document.getElementById("manualYear"),
manualCreator: document.getElementById("manualCreator"),
manualCover: document.getElementById("manualCover"),
manualNotes: document.getElementById("manualNotes"),
typeFilter: document.getElementById("typeFilter"),
progressFilter: document.getElementById("progressFilter"),
collectionSearch: document.getElementById("collectionSearch"),
sortSelect: document.getElementById("sortSelect"),
summary: document.getElementById("summary"),
collection: document.getElementById("collection"),
exportButton: document.getElementById("exportButton"),
importInput: document.getElementById("importInput"),
toast: document.getElementById("toast"),
};
initialize();
function initialize() {
elements.typeTabs.forEach((tab) => {
tab.addEventListener("click", () => setActiveType(tab.dataset.type));
});
elements.searchForm.addEventListener("submit", handleSearch);
elements.toggleManualButton.addEventListener("click", toggleManualForm);
elements.manualForm.addEventListener("submit", handleManualAdd);
elements.results.addEventListener("click", handleResultAction);
elements.collection.addEventListener("change", handleCollectionChange);
elements.collection.addEventListener("click", handleCollectionClick);
elements.collection.addEventListener("input", handleCollectionInput);
elements.typeFilter.addEventListener("change", updateFilters);
elements.progressFilter.addEventListener("change", updateFilters);
elements.collectionSearch.addEventListener("input", updateFilters);
elements.sortSelect.addEventListener("change", updateFilters);
elements.exportButton.addEventListener("click", exportCollection);
elements.importInput.addEventListener("change", importCollection);
renderResults();
renderCollection();
}
function setActiveType(type) {
if (!mediaTypes[type]) {
return;
}
state.activeType = type;
state.results = [];
elements.typeTabs.forEach((tab) => {
tab.classList.toggle("active", tab.dataset.type === type);
});
setSearchFeedback("Ready");
renderResults();
}
async function handleSearch(event) {
event.preventDefault();
const query = elements.searchInput.value.trim();
if (!query) {
setSearchFeedback("Enter a title to search.");
return;
}
setSearchFeedback(`Searching ${mediaTypes[state.activeType].label.toLowerCase()}...`);
elements.results.innerHTML = "";
try {
const results = await searchMedia(state.activeType, query);
state.results = results.slice(0, 12);
setSearchFeedback(results.length ? `${results.length} results found.` : "No matches found.");
} catch (error) {
state.results = [];
setSearchFeedback(`Search failed for ${mediaTypes[state.activeType].label}. Add it manually instead.`);
}
renderResults();
}
async function searchMedia(type, query) {
const adapters = {
movie: searchMovies,
tv: searchTv,
book: searchBooks,
game: searchGames,
};
return adapters[type](query);
}
async function searchMovies(query) {
const url = new URL("https://en.wikipedia.org/w/api.php");
url.searchParams.set("action", "query");
url.searchParams.set("generator", "search");
url.searchParams.set("gsrsearch", `${query} film`);
url.searchParams.set("gsrlimit", "12");
url.searchParams.set("prop", "pageimages|extracts");
url.searchParams.set("exintro", "1");
url.searchParams.set("explaintext", "1");
url.searchParams.set("exchars", "260");
url.searchParams.set("piprop", "thumbnail");
url.searchParams.set("pithumbsize", "300");
url.searchParams.set("format", "json");
url.searchParams.set("origin", "*");
const data = await fetchJson(url);
const pages = Object.values(data.query?.pages || {}).sort((a, b) => a.index - b.index);
const filmLikePages = pages.filter((page) => {
const text = `${page.title} ${page.extract || ""}`.toLowerCase();
return text.includes(" film") || text.includes("(film)") || text.includes(" movie");
});
return (filmLikePages.length ? filmLikePages : pages).map((movie) => ({
type: "movie",
source: "wikipedia",
sourceId: String(movie.pageid),
title: movie.title,
year: getYearFromText(movie.extract),
creator: "Wikipedia",
cover: movie.thumbnail?.source || "",
description: movie.extract || "",
externalUrl: `https://en.wikipedia.org/?curid=${movie.pageid}`,
}));
}
async function searchTv(query) {
const url = new URL("https://api.tvmaze.com/search/shows");
url.searchParams.set("q", query);
const data = await fetchJson(url);
return data.map(({ show }) => ({
type: "tv",
source: "tvmaze",
sourceId: String(show.id),
title: show.name,
year: getYear(show.premiered),
creator: [show.network?.name, show.webChannel?.name, show.status].filter(Boolean).join(" / ") || "TV Show",
cover: show.image?.medium || show.image?.original || "",
description: stripHtml(show.summary || ""),
externalUrl: show.url || "",
runtimeMinutes: show.averageRuntime || show.runtime || null,
}));
}
async function searchBooks(query) {
const url = new URL("https://openlibrary.org/search.json");
url.searchParams.set("title", query);
url.searchParams.set("limit", "12");
const data = await fetchJson(url);
return (data.docs || []).map((book) => ({
type: "book",
source: "openlibrary",
sourceId: book.key || `${book.title}-${book.first_publish_year || ""}`,
title: book.title,
year: book.first_publish_year || "",
creator: Array.isArray(book.author_name) ? book.author_name.slice(0, 2).join(", ") : "Book",
cover: book.cover_i ? `https://covers.openlibrary.org/b/id/${book.cover_i}-M.jpg` : "",
description: [
book.edition_count ? `${book.edition_count} editions` : "",
Array.isArray(book.language) ? `${book.language.length} languages` : "",
].filter(Boolean).join(" / "),
externalUrl: book.key ? `https://openlibrary.org${book.key}` : "",
}));
}
async function searchGames(query) {
const url = new URL("https://www.cheapshark.com/api/1.0/games");
url.searchParams.set("title", query);
url.searchParams.set("limit", "12");
const data = await fetchJson(url);
return data.map((game) => ({
type: "game",
source: "cheapshark",
sourceId: String(game.gameID || game.steamAppID || game.external),
title: game.external,
year: "",
creator: game.steamAppID ? "Steam" : "Game",
cover: normalizeImageUrl(game.thumb || ""),
description: game.cheapest ? `Lowest tracked price: $${game.cheapest}` : "",
externalUrl: game.steamAppID ? `https://store.steampowered.com/app/${game.steamAppID}` : "",
}));
}
async function fetchJson(url) {
const response = await fetch(url.toString(), {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}
function renderResults() {
if (!state.results.length) {
elements.results.innerHTML = "";
return;
}
elements.results.innerHTML = state.results.map(renderResultCard).join("");
bindCoverFallbacks(elements.results);
}
function renderResultCard(item, index) {
const type = mediaTypes[item.type];
const alreadyAdded = isInCollection(item);
const sourceLink = item.externalUrl
? `<a class="link-action" href="${escapeAttribute(item.externalUrl)}" target="_blank" rel="noreferrer">Source</a>`
: "";
return `
<article class="result-card">
${renderCover(item.cover, item.title)}
<div class="item-body">
<div class="item-title-row">
<div>
<h3 class="item-title">${escapeHtml(item.title)}</h3>
<p class="item-meta">${escapeHtml(formatMeta(item))}</p>
</div>
<span class="badge ${type.badge}">${type.singular}</span>
</div>
${item.description ? `<p class="item-description">${escapeHtml(item.description)}</p>` : ""}
<div class="result-actions">
<button class="button primary" type="button" data-add-result="${index}" ${alreadyAdded ? "disabled" : ""}>
${alreadyAdded ? "Added" : "Add"}
</button>
${sourceLink}
</div>
</div>
</article>
`;
}
function renderCollection() {
renderSummary();
const items = getFilteredCollection();
if (!items.length) {
elements.collection.innerHTML = `
<div class="empty-state">
<h3>No tracked items</h3>
<p>Your collection will appear here.</p>
</div>
`;
return;
}
elements.collection.innerHTML = items.map(renderCollectionCard).join("");
bindCoverFallbacks(elements.collection);
}
function renderSummary() {
const totals = state.collection.reduce(
(summary, item) => {
const progress = getItemProgress(item);
summary.total += 1;
summary[progress.key] += 1;
return summary;
},
{ total: 0, planned: 0, active: 0, complete: 0 },
);
elements.summary.innerHTML = `
<div class="summary-pill"><strong>${totals.total}</strong><span>Total</span></div>
<div class="summary-pill"><strong>${totals.active}</strong><span>Started</span></div>
<div class="summary-pill"><strong>${totals.complete}</strong><span>Finished</span></div>
`;
}
function renderCollectionCard(item) {
const type = mediaTypes[item.type];
const progress = getItemProgress(item);
const sourceLink = item.externalUrl
? `<a class="link-action" href="${escapeAttribute(item.externalUrl)}" target="_blank" rel="noreferrer">Source</a>`
: "";
return `
<article class="collection-card progress-${escapeAttribute(progress.key)}" data-item-id="${escapeAttribute(item.id)}">
${renderCover(item.cover, item.title)}
<div class="item-body">
<div class="item-title-row">
<div>
<h3 class="item-title">${escapeHtml(item.title)}</h3>
<p class="item-meta">${escapeHtml(formatMeta(item))}</p>
<p class="progress-line">${escapeHtml(getProgressText(item))}</p>
</div>
<span class="badge ${type.badge}">${type.singular}</span>
</div>
${item.description ? `<p class="item-description">${escapeHtml(item.description)}</p>` : ""}
<p class="date-line">Added ${formatDate(item.addedAt)}</p>
${renderItemDateFields(item)}
<div class="collection-actions">
<input class="notes-input" data-notes-input type="text" value="${escapeAttribute(item.notes || "")}" placeholder="Notes">
${sourceLink}
<button class="button subtle remove-button" type="button" data-remove-item>Remove</button>
</div>
</div>
${renderSeasonTracker(item)}
</article>
`;
}
function renderItemDateFields(item) {
return `
<div class="date-grid">
<label>
Start date
<input type="date" data-date-field="startedAt" value="${escapeAttribute(normalizeDateValue(item.startedAt))}">
</label>
<label>
Finish date
<input type="date" data-date-field="finishedAt" value="${escapeAttribute(normalizeDateValue(item.finishedAt))}">
</label>
</div>
`;
}
function renderSeasonTracker(item) {
if (item.type !== "tv") {
return "";
}
const seasons = normalizeSeasonData(item.seasons);
if (!seasons.length) {
const canLoadSeasons = item.source === "tvmaze" && item.sourceId;
return `
<div class="season-panel">
<p class="season-empty">${canLoadSeasons ? "Season data not loaded yet." : "Season tracking needs a TVMaze result."}</p>
${canLoadSeasons ? '<button class="button subtle season-load-button" type="button" data-load-seasons>Load seasons</button>' : ""}
</div>
`;
}
const finishedCount = seasons.filter((season) => getSeasonProgress(season).key === "complete").length;
const totalRuntime = seasons.reduce((sum, season) => sum + (season.runtimeMinutes || 0), 0);
return `
<div class="season-panel">
<div class="season-summary">
<span>${finishedCount}/${seasons.length} seasons finished</span>
<span>${formatRuntime(totalRuntime)}</span>
</div>
<div class="season-list">
${seasons.map(renderSeasonRow).join("")}
</div>
</div>
`;
}
function renderSeasonRow(season) {
const seasonLabel = season.number === 0 ? "Specials" : `Season ${season.number}`;
const episodeLabel = `${season.episodeCount || 0} ep${season.episodeCount === 1 ? "" : "s"}`;
const progress = getSeasonProgress(season);
return `
<div class="season-row progress-${escapeAttribute(progress.key)}">
<div class="season-title">
<span class="season-name">${escapeHtml(seasonLabel)}</span>
<span class="season-meta">${escapeHtml(episodeLabel)} / ${escapeHtml(formatRuntime(season.runtimeMinutes))}</span>
</div>
<label class="season-date-field">
Start
<input type="date" data-season-date-field="startedAt" data-season-number="${escapeAttribute(season.number)}" value="${escapeAttribute(normalizeDateValue(season.startedAt))}">
</label>
<label class="season-date-field">
Finish
<input type="date" data-season-date-field="finishedAt" data-season-number="${escapeAttribute(season.number)}" value="${escapeAttribute(normalizeDateValue(season.finishedAt))}">
</label>
</div>
`;
}
function renderCover(cover, title) {
if (cover) {
return `<img class="cover" src="${escapeAttribute(cover)}" alt="${escapeAttribute(title)} cover" loading="lazy" data-fallback-initials="${escapeAttribute(getInitials(title))}">`;
}
return `<div class="cover-placeholder">${escapeHtml(getInitials(title))}</div>`;
}
function bindCoverFallbacks(container) {
container.querySelectorAll("img[data-fallback-initials]").forEach((image) => {
image.addEventListener("error", () => {
image.replaceWith(createCoverPlaceholder(image.dataset.fallbackInitials || "?"));
}, { once: true });
});
}
function createCoverPlaceholder(initials) {
const placeholder = document.createElement("div");
placeholder.className = "cover-placeholder";
placeholder.textContent = initials;
return placeholder;
}
async function handleResultAction(event) {
const button = event.target.closest("[data-add-result]");
if (!button) {
return;
}
const item = state.results[Number(button.dataset.addResult)];
button.disabled = true;
button.textContent = item.type === "tv" ? "Loading..." : "Adding...";
try {
await addToCollection(item);
} finally {
renderResults();
}
}
async function handleManualAdd(event) {
event.preventDefault();
const title = elements.manualTitle.value.trim();
if (!title) {
return;
}
await addToCollection({
type: state.activeType,
source: "manual",
sourceId: createId(),
title,
year: elements.manualYear.value.trim(),
creator: elements.manualCreator.value.trim(),
cover: elements.manualCover.value.trim(),
description: elements.manualNotes.value.trim(),
externalUrl: "",
notes: elements.manualNotes.value.trim(),
});
elements.manualForm.reset();
}
async function addToCollection(item) {
if (isInCollection(item)) {
showToast(`${item.title} is already tracked.`);
return;
}
const now = new Date().toISOString();
const collectionItem = {
id: createId(),
type: item.type,
source: item.source || "manual",
sourceId: item.sourceId || createId(),
title: item.title,
year: item.year || "",
creator: item.creator || "",
cover: item.cover || "",
description: item.description || "",
externalUrl: item.externalUrl || "",
startedAt: normalizeDateValue(item.startedAt),
finishedAt: normalizeDateValue(item.finishedAt),
notes: item.notes || "",
seasons: normalizeSeasonData(item.seasons),
addedAt: now,
updatedAt: now,
};
if (collectionItem.type === "tv" && collectionItem.source === "tvmaze" && collectionItem.sourceId) {
try {
collectionItem.seasons = await fetchTvSeasons(collectionItem.sourceId);
} catch (error) {
collectionItem.seasons = [];
}
}
state.collection.unshift(collectionItem);
persistCollection();
renderResults();
renderCollection();
const seasonMessage = collectionItem.seasons.length ? ` with ${collectionItem.seasons.length} seasons` : "";
showToast(`${item.title} added${seasonMessage}.`);
}
function handleCollectionChange(event) {
const card = event.target.closest("[data-item-id]");
if (!card) {
return;
}
if (event.target.matches("[data-date-field]")) {
updateItem(card.dataset.itemId, {
[event.target.dataset.dateField]: normalizeDateValue(event.target.value),
});
}
if (event.target.matches("[data-season-date-field]")) {
const seasonNumber = Number(event.target.dataset.seasonNumber);
const field = event.target.dataset.seasonDateField;
const item = state.collection.find((entry) => entry.id === card.dataset.itemId);
const seasons = normalizeSeasonData(item?.seasons).map((season) => (
season.number === seasonNumber
? { ...season, [field]: normalizeDateValue(event.target.value) }
: season
));
updateItem(card.dataset.itemId, { seasons });
}
}
function handleCollectionInput(event) {
const card = event.target.closest("[data-item-id]");
if (!card || !event.target.matches("[data-notes-input]")) {
return;
}
updateItem(card.dataset.itemId, {
notes: event.target.value,
}, false);
}
async function handleCollectionClick(event) {
const loadButton = event.target.closest("[data-load-seasons]");
if (loadButton) {
const card = loadButton.closest("[data-item-id]");
loadButton.disabled = true;
loadButton.textContent = "Loading...";
await loadSeasonsForItem(card.dataset.itemId);
return;
}
const removeButton = event.target.closest("[data-remove-item]");
if (!removeButton) {
return;
}
const card = removeButton.closest("[data-item-id]");
const item = state.collection.find((entry) => entry.id === card.dataset.itemId);
state.collection = state.collection.filter((entry) => entry.id !== card.dataset.itemId);
persistCollection();
renderCollection();
showToast(`${item?.title || "Item"} removed.`);
}
async function loadSeasonsForItem(id) {
const item = state.collection.find((entry) => entry.id === id);
if (!item || item.type !== "tv" || item.source !== "tvmaze" || !item.sourceId) {
showToast("Season data is only available for TVMaze shows.");
renderCollection();
return;
}
try {
const seasons = await fetchTvSeasons(item.sourceId, item.seasons);
updateItem(id, { seasons });
showToast(`${item.title} seasons loaded.`);
} catch (error) {
showToast("Could not load season data right now.");
renderCollection();
}
}
function updateItem(id, changes, rerender = true) {
state.collection = state.collection.map((item) => {
if (item.id !== id) {
return item;
}
return {
...item,
...changes,
updatedAt: new Date().toISOString(),
};
});
persistCollection();
if (rerender) {
renderCollection();
}
}
function updateFilters() {
state.filters = {
type: elements.typeFilter.value,
progress: elements.progressFilter.value,
search: elements.collectionSearch.value.trim().toLowerCase(),
sort: elements.sortSelect.value,
};
renderCollection();
}
function getFilteredCollection() {
const { type, progress, search, sort } = state.filters;
const items = state.collection.filter((item) => {
const matchesType = type === "all" || item.type === type;
const matchesProgress = progress === "all" || getItemProgress(item).key === progress;
const haystack = [item.title, item.creator, item.year, item.notes].join(" ").toLowerCase();
const matchesSearch = !search || haystack.includes(search);
return matchesType && matchesProgress && matchesSearch;
});
return items.sort((a, b) => {
if (sort === "title") {
return a.title.localeCompare(b.title);
}
if (sort === "started") {
return compareDates(b.startedAt, a.startedAt);
}
if (sort === "finished") {
return compareDates(b.finishedAt, a.finishedAt);
}
const field = sort === "updated" ? "updatedAt" : "addedAt";
return compareDates(b[field], a[field]);
});
}
function toggleManualForm() {
const isHidden = elements.manualForm.classList.toggle("hidden");
elements.toggleManualButton.setAttribute("aria-expanded", String(!isHidden));
}
function exportCollection() {
const payload = {
exportedAt: new Date().toISOString(),
items: state.collection,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `media-tracker-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
URL.revokeObjectURL(url);
}
async function importCollection(event) {
const file = event.target.files?.[0];
if (!file) {
return;
}
try {
const text = await file.text();
const data = JSON.parse(text);
const imported = Array.isArray(data) ? data : data.items;
if (!Array.isArray(imported)) {
throw new Error("Invalid import file.");
}
const normalized = imported
.filter((item) => item && mediaTypes[item.type] && item.title)
.map((item) => ({
id: item.id || createId(),
type: item.type,
source: item.source || "manual",
sourceId: item.sourceId || createId(),
title: item.title,
year: item.year || "",
creator: item.creator || "",
cover: item.cover || "",
description: item.description || "",
externalUrl: item.externalUrl || "",
startedAt: normalizeDateValue(item.startedAt),
finishedAt: normalizeDateValue(item.finishedAt),
notes: item.notes || "",
seasons: normalizeSeasonData(item.seasons),
addedAt: item.addedAt || new Date().toISOString(),
updatedAt: item.updatedAt || new Date().toISOString(),
}));
state.collection = mergeCollections(state.collection, normalized);
persistCollection();
renderCollection();
showToast(`${normalized.length} items imported.`);
} catch (error) {
showToast("Import failed. Choose a tracker JSON file.");
} finally {
event.target.value = "";
}
}
function mergeCollections(existing, imported) {
const seen = new Set(existing.map(getCollectionKey));
const additions = imported.filter((item) => {
const key = getCollectionKey(item);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
return [...additions, ...existing];
}
function isInCollection(item) {
const key = getCollectionKey(item);
return state.collection.some((entry) => getCollectionKey(entry) === key);
}
function getCollectionKey(item) {
const sourceKey = item.source && item.source !== "manual" && item.sourceId ? `${item.source}:${item.sourceId}` : "";
return `${item.type}:${sourceKey || normalizeTitle(item.title)}`;
}
function persistCollection() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.collection));
}
function loadCollection() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
return Array.isArray(saved) ? saved : [];
} catch (error) {
return [];
}
}
function setSearchFeedback(message) {
elements.searchFeedback.textContent = message;
}
function showToast(message) {
elements.toast.textContent = message;
elements.toast.classList.remove("hidden");
clearTimeout(showToast.timeoutId);
showToast.timeoutId = setTimeout(() => {
elements.toast.classList.add("hidden");
}, 2600);
}
function formatMeta(item) {
return [item.year, item.creator].filter(Boolean).join(" / ") || mediaTypes[item.type].singular;
}
function formatDate(value) {
if (!value) {
return "unknown";
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(value));
}
async function fetchTvSeasons(showId, existingSeasons = []) {
const url = new URL(`https://api.tvmaze.com/shows/${showId}/episodes`);
const episodes = await fetchJson(url);
const existingBySeason = new Map(
normalizeSeasonData(existingSeasons).map((season) => [season.number, season]),
);
const grouped = new Map();
episodes.forEach((episode) => {
const seasonNumber = Number(episode.season);
if (!Number.isFinite(seasonNumber)) {
return;
}
const current = grouped.get(seasonNumber) || {
number: seasonNumber,
episodeCount: 0,
runtimeMinutes: 0,
startedAt: existingBySeason.get(seasonNumber)?.startedAt || "",
finishedAt: existingBySeason.get(seasonNumber)?.finishedAt || "",
};
current.episodeCount += 1;
current.runtimeMinutes += Number(episode.runtime) || 0;
grouped.set(seasonNumber, current);
});
return Array.from(grouped.values()).sort((a, b) => a.number - b.number);
}
function normalizeSeasonData(seasons) {
if (!Array.isArray(seasons)) {
return [];
}
return seasons
.map((season) => ({
number: Number(season.number),
episodeCount: Number(season.episodeCount) || 0,
runtimeMinutes: Number(season.runtimeMinutes) || 0,
startedAt: normalizeDateValue(season.startedAt || season.startDate),
finishedAt: normalizeDateValue(season.finishedAt || season.finishDate || season.completedAt),
}))
.filter((season) => Number.isFinite(season.number))
.sort((a, b) => a.number - b.number);
}
function formatRuntime(minutes) {
const totalMinutes = Number(minutes) || 0;
if (!totalMinutes) {
return "Runtime unknown";
}
const hours = Math.floor(totalMinutes / 60);
const remainingMinutes = totalMinutes % 60;
if (!hours) {
return `${remainingMinutes}m`;
}
return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}
function getItemProgress(item) {
if (normalizeDateValue(item.finishedAt)) {
return { key: "complete", label: mediaTypes[item.type]?.progressLabels.complete || "Finished" };
}
if (normalizeDateValue(item.startedAt)) {
return { key: "active", label: mediaTypes[item.type]?.progressLabels.active || "In progress" };
}
if (item.type === "tv") {
const seasons = normalizeSeasonData(item.seasons);
const finishedCount = seasons.filter((season) => normalizeDateValue(season.finishedAt)).length;
const startedCount = seasons.filter((season) => normalizeDateValue(season.startedAt)).length;
if (seasons.length && finishedCount === seasons.length) {
return { key: "complete", label: "All seasons finished" };
}
if (finishedCount || startedCount) {
return { key: "active", label: "Season dates started" };
}
}
return { key: "planned", label: mediaTypes[item.type]?.progressLabels.planned || "No dates" };
}
function getSeasonProgress(season) {
if (normalizeDateValue(season.finishedAt)) {
return { key: "complete", label: "Finished" };
}
if (normalizeDateValue(season.startedAt)) {
return { key: "active", label: "Started" };
}
return { key: "planned", label: "No dates" };
}
function getProgressText(item) {
const progress = getItemProgress(item);
const startedAt = normalizeDateValue(item.startedAt);
const finishedAt = normalizeDateValue(item.finishedAt);
if (item.type === "tv" && !startedAt && !finishedAt && progress.key !== "planned") {
const seasons = normalizeSeasonData(item.seasons);
const finishedCount = seasons.filter((season) => normalizeDateValue(season.finishedAt)).length;
return `${progress.label} (${finishedCount}/${seasons.length})`;
}
if (progress.key === "complete") {
return `${progress.label} ${formatShortDate(finishedAt)}`;
}
if (progress.key === "active") {
return `${progress.label} ${formatShortDate(startedAt)}`;
}
return progress.label;
}
function normalizeDateValue(value) {
if (!value) {
return "";
}
const text = String(value).trim();
const dateMatch = text.match(/^\d{4}-\d{2}-\d{2}/);
if (!dateMatch) {
return "";
}
const date = new Date(`${dateMatch[0]}T00:00:00`);
return Number.isNaN(date.getTime()) ? "" : dateMatch[0];
}
function formatShortDate(value) {
const dateValue = normalizeDateValue(value);
return dateValue ? `on ${formatDate(dateValue)}` : "";
}
function compareDates(left, right) {
const leftDate = normalizeDateValue(left) || left || "";
const rightDate = normalizeDateValue(right) || right || "";
return new Date(leftDate || 0).getTime() - new Date(rightDate || 0).getTime();
}
function getYear(value) {
return value ? String(value).slice(0, 4) : "";
}
function getYearFromText(value) {
const match = String(value || "").match(/\b(19|20)\d{2}\b/);
return match ? match[0] : "";
}
function stripHtml(value) {
const doc = new DOMParser().parseFromString(value, "text/html");
return doc.body.textContent || "";
}
function normalizeImageUrl(value) {
if (!value) {
return "";
}
return value.startsWith("http://") ? value.replace("http://", "https://") : value;
}
function normalizeTitle(value) {
return String(value || "").trim().toLowerCase().replace(/\s+/g, " ");
}
function getInitials(title) {
return String(title || "?")
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}
function createId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function escapeAttribute(value) {
return escapeHtml(value);
}