wip implementation of local library on anime

This commit is contained in:
2025-12-26 22:36:16 +01:00
parent 6075dcf149
commit 25ea30f086
9 changed files with 645 additions and 328 deletions

View File

@@ -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(

View File

@@ -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);
}

View File

@@ -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 = `<p>Ep ${epNumber}: ${ep.title || 'Untitled'}</p>`;
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 = '<option>No Extensions</option>';
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();

View File

@@ -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 = `
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
`;
} else {
// ONLINE MODE
btn.classList.remove('active');
onlineContent.classList.remove('hidden');
localContent.classList.add('hidden');
svg.innerHTML = `
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
`;
}
}
async function loadLocalEntries() {
const grid = document.getElementById('local-entries-grid');
grid.innerHTML = '<div class="skeleton-card"></div>'.repeat(8);
try {
const response = await fetch('/api/library/anime');
const entries = await response.json();
localEntries = entries;
if (entries.length === 0) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-text-secondary); padding: 3rem;">No anime found in your local library. Click "Scan Library" to scan your folders.</p>';
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 `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${score}% • ${episodes} Eps
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
} catch (err) {
console.error('Error loading local entries:', err);
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: var(--color-danger); padding: 3rem;">Error loading local library. Make sure the backend is running.</p>';
}
}
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 `
<div class="local-card" onclick="viewLocalEntry(${entry.metadata?.id || 'null'})">
<div class="card-img-wrap">
<img src="${cover}" alt="${title}" loading="lazy">
</div>
<div class="local-card-info">
<div class="local-card-title">${title}</div>
<p style="font-size: 0.85rem; color: var(--color-text-secondary); margin: 0;">
${score}% • ${episodes} Eps
</p>
<div class="match-status ${entry.matched ? 'status-linked' : 'status-unlinked'}">
${entry.matched ? '● Linked' : '○ Unlinked'}
</div>
</div>
</div>
`;
}).join('');
}
function applyLocalFilters() {
let filtered = [...localEntries];
if (activeFilter === 'linked') {
filtered = filtered.filter(e => e.matched);
}
if (activeFilter === 'unlinked') {
filtered = filtered.filter(e => !e.matched);
}
if (activeSort === 'az') {
filtered.sort((a, b) =>
(a.metadata?.title?.romaji || a.id)
.localeCompare(b.metadata?.title?.romaji || b.id)
);
}
if (activeSort === 'za') {
filtered.sort((a, b) =>
(b.metadata?.title?.romaji || b.id)
.localeCompare(a.metadata?.title?.romaji || a.id)
);
}
renderLocalEntries(filtered);
}
document.addEventListener('click', e => {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
if (btn.dataset.filter) {
activeFilter = btn.dataset.filter;
}
if (btn.dataset.sort) {
activeSort = btn.dataset.sort;
}
btn
.closest('.local-filters')
.querySelectorAll('.filter-btn')
.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyLocalFilters();
});

View File

@@ -2,7 +2,7 @@ const sqlite3 = require('sqlite3').verbose();
const os = require("os");
const path = require("path");
const fs = require("fs");
const {ensureUserDataDB, ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, ensureFavoritesDB} = require('./schemas');
const {ensureUserDataDB, ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, ensureFavoritesDB, ensureLocalLibrarySchema } = require('./schemas');
const databases = new Map();
@@ -10,7 +10,8 @@ const DEFAULT_PATHS = {
anilist: path.join(os.homedir(), "WaifuBoards", 'anilist_anime.db'),
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"),
cache: path.join(os.homedir(), "WaifuBoards", "cache.db"),
userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db")
userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db"),
local_library: path.join(os.homedir(), "WaifuBoards", "local_library.db")
};
function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
@@ -49,6 +50,11 @@ function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
databases.set(name, db);
if (name === "local_library") {
ensureLocalLibrarySchema(db)
.catch(err => console.error("Error creating local library schema:", err));
}
if (name === "anilist") {
ensureAnilistSchema(db)
.then(() => ensureExtensionsTable(db))