diff --git a/desktop/src/api/local/local.controller.ts b/desktop/src/api/local/local.controller.ts index 6e535c5..76b8094 100644 --- a/desktop/src/api/local/local.controller.ts +++ b/desktop/src/api/local/local.controller.ts @@ -123,13 +123,20 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue ); } - const files = fs.readdirSync(fullPath, { withFileTypes: true }).filter(f => f.isFile()); + const files = fs.readdirSync(fullPath, { withFileTypes: true }) + .filter(f => f.isFile()) + .sort((a, b) => a.name.localeCompare(b.name)); + + let unit = 1; + for (const file of files) { await run( - `INSERT INTO local_files (id, entry_id, file_path) VALUES (?, ?, ?)`, - [crypto.randomUUID(), id, path.join(fullPath, file.name)], + `INSERT INTO local_files (id, entry_id, file_path, unit_number) + VALUES (?, ?, ?, ?)`, + [crypto.randomUUID(), id, path.join(fullPath, file.name), unit], 'local_library' ); + unit++; } } } @@ -179,10 +186,7 @@ export async function getEntry(request: FastifyRequest<{ Params: Params }>, repl } } -export async function streamUnit( - request: FastifyRequest, - reply: FastifyReply -) { +export async function streamUnit(request: FastifyRequest, reply: FastifyReply) { const { id, unit } = request.params as any; const file = await queryOne( diff --git a/desktop/src/scripts/anime/anime.js b/desktop/src/scripts/anime/anime.js index f605e3c..2ea41f0 100644 --- a/desktop/src/scripts/anime/anime.js +++ b/desktop/src/scripts/anime/anime.js @@ -1,6 +1,7 @@ let animeData = null; let extensionName = null; let animeId = null; +let isLocal = false; const episodePagination = Object.create(PaginationManager); episodePagination.init(12, renderEpisodes); @@ -13,6 +14,30 @@ document.addEventListener('DOMContentLoaded', () => { setupEpisodeSearch(); }); +function markAsLocal() { + isLocal = true; + const pill = document.getElementById('local-pill'); + if (!pill) return; + + pill.textContent = 'Local'; + pill.style.display = 'inline-flex'; + pill.style.background = 'rgba(34,197,94,.2)'; + pill.style.color = '#22c55e'; + pill.style.borderColor = 'rgba(34,197,94,.3)'; +} + +async function checkLocalLibraryEntry() { + try { + const res = await fetch(`/api/library/anime/${animeId}`); + if (!res.ok) return; + + markAsLocal(); + + } catch (e) { + } +} + + async function loadAnime() { try { @@ -24,6 +49,7 @@ async function loadAnime() { extensionName = urlData.extensionName; animeId = urlData.entityId; + await checkLocalLibraryEntry(); const fetchUrl = extensionName ? `/api/anime/${animeId}?source=${extensionName}` @@ -142,8 +168,8 @@ function setupWatchButton() { const watchBtn = document.getElementById('watch-btn'); if (watchBtn) { watchBtn.onclick = () => { - const url = URLUtils.buildWatchUrl(animeId, 1, extensionName); - window.location.href = url; + const source = isLocal ? 'local' : (extensionName || 'anilist'); + window.location.href = URLUtils.buildWatchUrl(animeId, num, source); }; } } @@ -226,8 +252,8 @@ function createEpisodeButton(num, container) { btn.className = 'episode-btn'; btn.innerText = `Ep ${num}`; btn.onclick = () => { - const url = URLUtils.buildWatchUrl(animeId, num, extensionName); - window.location.href = url; + const source = isLocal ? 'local' : (extensionName || 'anilist'); + window.location.href = URLUtils.buildWatchUrl(animeId, num, source); }; container.appendChild(btn); } diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index 42ba644..113a9d8 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -18,12 +18,28 @@ const firstKey = params.keys().next().value; let extName; if (firstKey) extName = firstKey; -const href = extName +// URL de retroceso: Si es local, volvemos a la vista de Anilist normal +const href = (extName && extName !== 'local') ? `/anime/${extName}/${animeId}` : `/anime/${animeId}`; document.getElementById('back-link').href = href; document.getElementById('episode-label').innerText = `Episode ${currentEpisode}`; + + +let localEntryId = null; + +async function checkLocal() { + try { + const res = await fetch(`/api/library/anime/${animeId}`); + if (!res.ok) return; + + const data = await res.json(); + localEntryId = data.id; // ← ID interna + + } catch {} +} + async function loadAniSkip(malId, episode, duration) { try { const res = await fetch(`https://api.aniskip.com/v2/skip-times/${malId}/${episode}?types[]=op&types[]=ed&episodeLength=${duration}`); @@ -37,9 +53,10 @@ async function loadAniSkip(malId, episode, duration) { } async function loadMetadata() { + checkLocal(); try { - const extQuery = extName ? `?source=${extName}` : "?source=anilist"; - const res = await fetch(`/api/anime/${animeId}${extQuery}`); + const sourceQuery = (extName === 'local' || !extName) ? "source=anilist" : `source=${extName}`; + const res = await fetch(`/api/anime/${animeId}?${sourceQuery}`); const data = await res.json(); if (data.error) { @@ -49,13 +66,7 @@ async function loadMetadata() { const isAnilistFormat = data.title && (data.title.romaji || data.title.english); - let title = ''; - let description = ''; - let coverImage = ''; - let averageScore = ''; - let format = ''; - let seasonYear = ''; - let season = ''; + let title = '', description = '', coverImage = '', averageScore = '', format = '', seasonYear = '', season = ''; if (isAnilistFormat) { title = data.title.romaji || data.title.english || data.title.native || 'Anime Title'; @@ -97,7 +108,8 @@ async function loadMetadata() { document.getElementById('detail-season').innerText = season && seasonYear ? `${season} ${seasonYear}` : (season || seasonYear || '--'); document.getElementById('detail-cover-image').src = coverImage || '/default-cover.jpg'; - if (extName) { + // Solo cargamos episodios de extensión si hay extensión real y no es local + if (extName && extName !== 'local') { await loadExtensionEpisodes(); } else { if (data.nextAiringEpisode?.episode) { @@ -109,12 +121,7 @@ async function loadMetadata() { } const simpleEpisodes = []; for (let i = 1; i <= totalEpisodes; i++) { - simpleEpisodes.push({ - number: i, - title: null, - thumbnail: null, - isDub: false - }); + simpleEpisodes.push({ number: i, title: null, thumbnail: null, isDub: false }); } populateEpisodeCarousel(simpleEpisodes); } @@ -129,72 +136,30 @@ async function loadMetadata() { } async function applyAniSkip(video) { - if (!isAnilist || !malId) { - console.log('AniSkip disabled: isAnilist=' + isAnilist + ', malId=' + malId); - return; - } + if (!isAnilist || !malId) return; - console.log('Loading AniSkip for MAL ID:', malId, 'Episode:', currentEpisode); + aniSkipData = await loadAniSkip(malId, currentEpisode, Math.floor(video.duration)); - aniSkipData = await loadAniSkip( - malId, - currentEpisode, - Math.floor(video.duration) - ); + if (!aniSkipData || aniSkipData.length === 0) return; - console.log('AniSkip data received:', aniSkipData); - - if (!aniSkipData || aniSkipData.length === 0) { - console.log('No AniSkip data available'); - return; - } - - let op, ed; const markers = []; - aniSkipData.forEach(item => { const { startTime, endTime } = item.interval; - - if (item.skipType === 'op') { - op = { start: startTime, end: endTime }; - markers.push({ - start: startTime, - end: endTime, - label: 'Opening' - }); - - console.log('Opening found:', startTime, '-', endTime); - } - - if (item.skipType === 'ed') { - ed = { start: startTime, end: endTime }; - markers.push({ - start: startTime, - end: endTime, - label: 'Ending' - }); - - console.log('Ending found:', startTime, '-', endTime); - } + markers.push({ + start: startTime, + end: endTime, + label: item.skipType === 'op' ? 'Opening' : 'Ending' + }); }); - // Crear markers visuales en el DOM if (plyrInstance && markers.length > 0) { - console.log('Creating visual markers:', markers); - - // Esperar a que el player esté completamente cargado setTimeout(() => { const progressContainer = document.querySelector('.plyr__progress'); - if (!progressContainer) { - console.error('Progress container not found'); - return; - } + if (!progressContainer) return; - // Eliminar markers anteriores si existen const oldMarkers = progressContainer.querySelector('.plyr__markers'); if (oldMarkers) oldMarkers.remove(); - // Crear contenedor de markers const markersContainer = document.createElement('div'); markersContainer.className = 'plyr__markers'; @@ -216,35 +181,19 @@ async function applyAniSkip(video) { markersContainer.appendChild(markerElement); }); - - progressContainer.appendChild(markersContainer); - console.log('Visual markers created successfully'); }, 500); } } async function loadExtensionEpisodes() { try { - const extQuery = extName ? `?source=${extName}` : "?source=anilist"; - const res = await fetch(`/api/anime/${animeId}/episodes${extQuery}`); + const res = await fetch(`/api/anime/${animeId}/episodes?source=${extName}`); const data = await res.json(); - totalEpisodes = Array.isArray(data) ? data.length : 0; - - if (Array.isArray(data) && data.length > 0) { - populateEpisodeCarousel(data); - } else { - - const fallback = []; - for (let i = 1; i <= totalEpisodes; i++) { - fallback.push({ number: i, title: null, thumbnail: null }); - } - populateEpisodeCarousel(fallback); - } + populateEpisodeCarousel(Array.isArray(data) ? data : []); } catch (e) { - console.error("Error cargando episodios por extensión:", e); - totalEpisodes = 0; + console.error("Error cargando episodios:", e); } } @@ -256,15 +205,12 @@ function populateEpisodeCarousel(episodesData) { const epNumber = ep.number || ep.episodeNumber || ep.id || (index + 1); if (!epNumber) return; - const extParam = extName ? `?${extName}` : ""; + const extParam = (extName && extName !== 'local') ? `?${extName}` : ""; const hasThumbnail = ep.thumbnail && ep.thumbnail.trim() !== ''; const link = document.createElement('a'); link.href = `/watch/${animeId}/${epNumber}${extParam}`; link.classList.add('carousel-item'); - link.dataset.episode = epNumber; - - if (!hasThumbnail) link.classList.add('no-thumbnail'); if (parseInt(epNumber) === currentEpisode) link.classList.add('active-ep-carousel'); const imgContainer = document.createElement('div'); @@ -272,21 +218,15 @@ function populateEpisodeCarousel(episodesData) { if (hasThumbnail) { const img = document.createElement('img'); - img.classList.add('carousel-item-img'); img.src = ep.thumbnail; - img.alt = `Episode ${epNumber} Thumbnail`; + img.classList.add('carousel-item-img'); imgContainer.appendChild(img); } link.appendChild(imgContainer); - const info = document.createElement('div'); info.classList.add('carousel-item-info'); - - const title = document.createElement('p'); - title.innerText = `Ep ${epNumber}: ${ep.title || 'Untitled'}`; - - info.appendChild(title); + info.innerHTML = `
Ep ${epNumber}: ${ep.title || 'Untitled'}
`; link.appendChild(info); carousel.appendChild(link); }); @@ -297,28 +237,26 @@ async function loadExtensions() { const res = await fetch('/api/extensions/anime'); const data = await res.json(); const select = document.getElementById('extension-select'); + let extensions = data.extensions || []; - if (data.extensions && data.extensions.length > 0) { - select.innerHTML = ''; - data.extensions.forEach(ext => { - const opt = document.createElement('option'); - opt.value = opt.innerText = ext; - select.appendChild(opt); - }); + // Añadimos local manualmente + if (!extensions.includes('local')) extensions.push('local'); - if (typeof extName === 'string' && data.extensions.includes(extName)) { - select.value = extName; - } else { - select.selectedIndex = 0; - } + select.innerHTML = ''; + extensions.forEach(ext => { + const opt = document.createElement('option'); + opt.value = opt.innerText = ext; + select.appendChild(opt); + }); - currentExtension = select.value; - onExtensionChange(); + if (extName && extensions.includes(extName)) { + select.value = extName; } else { - select.innerHTML = ''; - select.disabled = true; - setLoading("No anime extensions found."); + select.value = 'local'; // Default a local } + + currentExtension = select.value; + onExtensionChange(); } catch (error) { console.error("Extension Error:", error); } @@ -327,83 +265,70 @@ async function loadExtensions() { async function onExtensionChange() { const select = document.getElementById('extension-select'); currentExtension = select.value; - setLoading("Fetching extension settings..."); + if (currentExtension === 'local') { + document.getElementById('sd-toggle').style.display = 'none'; + document.getElementById('server-select').style.display = 'none'; + loadStream(); + return; + } + + setLoading("Fetching extension settings..."); try { const res = await fetch(`/api/extensions/${currentExtension}/settings`); const settings = await res.json(); const toggle = document.getElementById('sd-toggle'); - if (settings.supportsDub) { - toggle.style.display = 'flex'; - setAudioMode('sub'); - } else { - toggle.style.display = 'none'; - setAudioMode('sub'); - } + toggle.style.display = settings.supportsDub ? 'flex' : 'none'; + setAudioMode('sub'); const serverSelect = document.getElementById('server-select'); serverSelect.innerHTML = ''; - if (settings.episodeServers && settings.episodeServers.length > 0) { + if (settings.episodeServers?.length > 0) { settings.episodeServers.forEach(srv => { const opt = document.createElement('option'); - opt.value = srv; - opt.innerText = srv; + opt.value = opt.innerText = srv; serverSelect.appendChild(opt); }); serverSelect.style.display = 'block'; } else { serverSelect.style.display = 'none'; } - loadStream(); } catch (error) { - console.error(error); - setLoading("Failed to load extension settings."); + setLoading("Failed to load settings."); } } -function toggleAudioMode() { - const newMode = audioMode === 'sub' ? 'dub' : 'sub'; - setAudioMode(newMode); - loadStream(); -} - -function setAudioMode(mode) { - audioMode = mode; - const toggle = document.getElementById('sd-toggle'); - const subOpt = document.getElementById('opt-sub'); - const dubOpt = document.getElementById('opt-dub'); - - toggle.setAttribute('data-state', mode); - subOpt.classList.toggle('active', mode === 'sub'); - dubOpt.classList.toggle('active', mode === 'dub'); -} - async function loadStream() { if (!currentExtension) return; + if (currentExtension === 'local') { + console.log(localEntryId); + if (!localEntryId) { + setLoading("No existe en local"); + return; + } + + const localUrl = `/api/library/stream/anime/${localEntryId}/${currentEpisode}`; + playVideo(localUrl, []); + document.getElementById('loading-overlay').style.display = 'none'; + return; + } + + const serverSelect = document.getElementById('server-select'); const server = serverSelect.value || "default"; - setLoading(`Loading stream (${audioMode})...`); try { - let sourc = "&source=anilist"; - if (extName){ - sourc = `&source=${extName}`; - } + const sourc = (extName && extName !== 'local') ? `&source=${extName}` : "&source=anilist"; const url = `/api/watch/stream?animeId=${animeId}&episode=${currentEpisode}&server=${server}&category=${audioMode}&ext=${currentExtension}${sourc}`; const res = await fetch(url); const data = await res.json(); - if (data.error) { - setLoading(`Error: ${data.error}`); - return; - } - - if (!data.videoSources || data.videoSources.length === 0) { - setLoading("No video sources found."); + if (data.error || !data.videoSources?.length) { + setLoading(data.error || "No video sources."); return; } @@ -415,34 +340,31 @@ async function loadStream() { if (headers['Origin']) proxyUrl += `&origin=${encodeURIComponent(headers['Origin'])}`; if (headers['User-Agent']) proxyUrl += `&userAgent=${encodeURIComponent(headers['User-Agent'])}`; - playVideo(proxyUrl, data.videoSources[0].subtitles || data.subtitles); + playVideo(proxyUrl, source.subtitles || data.subtitles || []); document.getElementById('loading-overlay').style.display = 'none'; } catch (error) { - setLoading("Stream error. Check console."); - console.error(error); + setLoading("Stream error."); } } function playVideo(url, subtitles = []) { const video = document.getElementById('player'); + const isLocal = url.includes('/api/library/stream/'); - if (Hls.isSupported()) { + if (!isLocal && Hls.isSupported()) { if (hlsInstance) hlsInstance.destroy(); hlsInstance = new Hls({ xhrSetup: (xhr) => xhr.withCredentials = false }); hlsInstance.loadSource(url); hlsInstance.attachMedia(video); - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + } else { + if (hlsInstance) hlsInstance.destroy(); video.src = url; } if (plyrInstance) plyrInstance.destroy(); - - while (video.textTracks.length > 0) { - video.removeChild(video.textTracks[0]); - } + while (video.textTracks.length > 0) video.removeChild(video.textTracks[0]); subtitles.forEach(sub => { - if (!sub.url) return; const track = document.createElement('track'); track.kind = 'captions'; track.label = sub.language || 'Unknown'; @@ -455,118 +377,61 @@ function playVideo(url, subtitles = []) { plyrInstance = new Plyr(video, { captions: { active: true, update: true, language: 'en' }, controls: ['play-large', 'play', 'progress', 'current-time', 'duration', 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', 'fullscreen'], - settings: ['captions', 'quality', 'speed'], - markers: { - enabled: true, - points: [] - } + settings: ['captions', 'quality', 'speed'] }); - video.addEventListener('loadedmetadata', () => { - applyAniSkip(video); - }); + video.addEventListener('loadedmetadata', () => applyAniSkip(video)); + // LÓGICA DE RPC (Discord) let rpcActive = false; - let lastSeek = 0; - video.addEventListener("play", () => { if (!video.duration) return; - const elapsed = Math.floor(video.currentTime); const start = Math.floor(Date.now() / 1000) - elapsed; const end = start + Math.floor(video.duration); - - sendRPC({ - startTimestamp: start, - endTimestamp: end - }); - + sendRPC({ startTimestamp: start, endTimestamp: end }); rpcActive = true; }); video.addEventListener("pause", () => { - if (!rpcActive) return; - - sendRPC({ - paused: true - }); - }); - - video.addEventListener("seeking", () => { - lastSeek = video.currentTime; + if (rpcActive) sendRPC({ paused: true }); }); video.addEventListener("seeked", () => { if (video.paused || !rpcActive) return; - const elapsed = Math.floor(video.currentTime); const start = Math.floor(Date.now() / 1000) - elapsed; const end = start + Math.floor(video.duration); - - sendRPC({ - startTimestamp: start, - endTimestamp: end - }); + sendRPC({ startTimestamp: start, endTimestamp: end }); }); - - function sendRPC({ startTimestamp, endTimestamp, paused = false } = {}) { - fetch("/api/rpc", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - details: animeTitle, - state: `Episode ${currentEpisode}`, - mode: "watching", - startTimestamp, - endTimestamp, - paused - }) - }); - } } -function setLoading(message) { - const overlay = document.getElementById('loading-overlay'); - const text = document.getElementById('loading-text'); - overlay.style.display = 'flex'; - text.innerText = message; +function sendRPC({ startTimestamp, endTimestamp, paused = false } = {}) { + fetch("/api/rpc", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + details: animeTitle, + state: `Episode ${currentEpisode}`, + mode: "watching", + startTimestamp, + endTimestamp, + paused + }) + }); } -const extParam = extName ? `?${extName}` : ""; - -document.getElementById('prev-btn').onclick = () => { - if (currentEpisode > 1) { - window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`; - } -}; - -document.getElementById('next-btn').onclick = () => { - if (currentEpisode < totalEpisodes || totalEpisodes === 0) { - window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`; - } -}; - -if (currentEpisode <= 1) { - document.getElementById('prev-btn').disabled = true; -} - - async function sendProgress() { const token = localStorage.getItem('token'); if (!token) return; - - const source = extName - ? extName - : "anilist"; + const source = (extName && extName !== 'local') ? extName : "anilist"; const body = { entry_id: animeId, source: source, entry_type: "ANIME", status: 'CURRENT', - progress: source === 'anilist' - ? Math.floor(currentEpisode) - : currentEpisode + progress: currentEpisode }; try { @@ -583,7 +448,39 @@ async function sendProgress() { } } +// Botones y Toggle +document.getElementById('sd-toggle').onclick = () => { + audioMode = audioMode === 'sub' ? 'dub' : 'sub'; + setAudioMode(audioMode); + loadStream(); +}; + +function setAudioMode(mode) { + const toggle = document.getElementById('sd-toggle'); + toggle.setAttribute('data-state', mode); + document.getElementById('opt-sub').classList.toggle('active', mode === 'sub'); + document.getElementById('opt-dub').classList.toggle('active', mode === 'dub'); +} + +function setLoading(message) { + document.getElementById('loading-text').innerText = message; + document.getElementById('loading-overlay').style.display = 'flex'; +} + +const extParam = (extName && extName !== 'local') ? `?${extName}` : ""; +document.getElementById('prev-btn').onclick = () => { + if (currentEpisode > 1) window.location.href = `/watch/${animeId}/${currentEpisode - 1}${extParam}`; +}; +document.getElementById('next-btn').onclick = () => { + if (currentEpisode < totalEpisodes || totalEpisodes === 0) window.location.href = `/watch/${animeId}/${currentEpisode + 1}${extParam}`; +}; + +if (currentEpisode <= 1) document.getElementById('prev-btn').disabled = true; + +// Actualizar progreso cada 1 minuto si el video está reproduciéndose +setInterval(() => { + if (plyrInstance && !plyrInstance.paused) sendProgress(); +}, 60000); loadMetadata(); -loadExtensions(); - +loadExtensions(); \ No newline at end of file diff --git a/desktop/src/scripts/local-library.js b/desktop/src/scripts/local-library.js new file mode 100644 index 0000000..76c89aa --- /dev/null +++ b/desktop/src/scripts/local-library.js @@ -0,0 +1,209 @@ +let activeFilter = 'all'; +let activeSort = 'az'; +let isLocalMode = false; +let localEntries = []; + +function toggleLibraryMode() { + isLocalMode = !isLocalMode; + + const btn = document.getElementById('library-mode-btn'); + const onlineContent = document.getElementById('online-content'); + const localContent = document.getElementById('local-content'); + const svg = btn.querySelector('svg'); + const label = btn.querySelector('span'); + + if (isLocalMode) { + // LOCAL MODE + btn.classList.add('active'); + onlineContent.classList.add('hidden'); + localContent.classList.remove('hidden'); + loadLocalEntries(); + + svg.innerHTML = ` +No anime found in your local library. Click "Scan Library" to scan your folders.
'; + + return; + } + + // Renderizar grid + grid.innerHTML = entries.map(entry => { + const title = entry.metadata?.title?.romaji || entry.metadata?.title?.english || entry.id; + const cover = entry.metadata?.coverImage?.extraLarge || entry.metadata?.coverImage?.large || '/public/assets/placeholder.jpg'; + const score = entry.metadata?.averageScore || '--'; + const episodes = entry.metadata?.episodes || '??'; + + return ` ++ ${score}% • ${episodes} Eps +
+Error loading local library. Make sure the backend is running.
'; + } +} + + +async function scanLocalLibrary() { + const btnText = document.getElementById('scan-text'); + const originalText = btnText.innerText; + btnText.innerText = "Scanning..."; + + try { + const response = await fetch('/api/library/scan?mode=incremental', { + method: 'POST' + }); + + if (response.ok) { + await loadLocalEntries(); + // Mostrar notificación de éxito si tienes sistema de notificaciones + if (window.NotificationUtils) { + NotificationUtils.show('Library scanned successfully!', 'success'); + } + } else { + throw new Error('Scan failed'); + } + } catch (err) { + console.error("Scan failed", err); + alert("Failed to scan library. Check console for details."); + + // Mostrar notificación de error si tienes sistema de notificaciones + if (window.NotificationUtils) { + NotificationUtils.show('Failed to scan library', 'error'); + } + } finally { + btnText.innerText = originalText; + } +} + +function viewLocalEntry(anilistId) { + if (!anilistId) { + console.warn('Anime not linked'); + return; + } + window.location.href = `/anime/${anilistId}`; +} + +function renderLocalEntries(entries) { + const grid = document.getElementById('local-entries-grid'); + + grid.innerHTML = entries.map(entry => { + const title = entry.metadata?.title?.romaji + || entry.metadata?.title?.english + || entry.id; + + const cover = + entry.metadata?.coverImage?.extraLarge + || entry.metadata?.coverImage?.large + || '/public/assets/placeholder.jpg'; + + const score = entry.metadata?.averageScore || '--'; + const episodes = entry.metadata?.episodes || '??'; + + return ` ++ ${score}% • ${episodes} Eps +
+