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 ? `Source` : ""; return `
${renderCover(item.cover, item.title)}

${escapeHtml(item.title)}

${escapeHtml(formatMeta(item))}

${type.singular}
${item.description ? `

${escapeHtml(item.description)}

` : ""}
${sourceLink}
`; } function renderCollection() { renderSummary(); const items = getFilteredCollection(); if (!items.length) { elements.collection.innerHTML = `

No tracked items

Your collection will appear here.

`; 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 = `
${totals.total}Total
${totals.active}Started
${totals.complete}Finished
`; } function renderCollectionCard(item) { const type = mediaTypes[item.type]; const progress = getItemProgress(item); const sourceLink = item.externalUrl ? `Source` : ""; return `
${renderCover(item.cover, item.title)}

${escapeHtml(item.title)}

${escapeHtml(formatMeta(item))}

${escapeHtml(getProgressText(item))}

${type.singular}
${item.description ? `

${escapeHtml(item.description)}

` : ""} ${renderItemDateFields(item)}
${sourceLink}
${renderSeasonTracker(item)}
`; } function renderItemDateFields(item) { return `
`; } function renderSeasonTracker(item) { if (item.type !== "tv") { return ""; } const seasons = normalizeSeasonData(item.seasons); if (!seasons.length) { const canLoadSeasons = item.source === "tvmaze" && item.sourceId; return `

${canLoadSeasons ? "Season data not loaded yet." : "Season tracking needs a TVMaze result."}

${canLoadSeasons ? '' : ""}
`; } const finishedCount = seasons.filter((season) => getSeasonProgress(season).key === "complete").length; const totalRuntime = seasons.reduce((sum, season) => sum + (season.runtimeMinutes || 0), 0); return `
${finishedCount}/${seasons.length} seasons finished ${formatRuntime(totalRuntime)}
${seasons.map(renderSeasonRow).join("")}
`; } 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 `
${escapeHtml(seasonLabel)} ${escapeHtml(episodeLabel)} / ${escapeHtml(formatRuntime(season.runtimeMinutes))}
`; } function renderCover(cover, title) { if (cover) { return `${escapeAttribute(title)} cover`; } return `
${escapeHtml(getInitials(title))}
`; } 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("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function escapeAttribute(value) { return escapeHtml(value); }