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();

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))

View File

@@ -158,6 +158,7 @@
<div class="meta-row">
<div class="pill extension-pill" id="extension-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill" id="local-pill" style="display: none; background: #8b5cf6;"></div>
<div class="pill score" id="score">--% Score</div>
<div class="pill" id="year">----</div>
<div class="pill" id="genres">Action</div>

View File

@@ -9,17 +9,19 @@
<link rel="stylesheet" href="/views/css/components/hero.css">
<link rel="stylesheet" href="/views/css/components/anilist-modal.css">
<link rel="stylesheet" href="/views/css/components/updateNotifier.css">
<link rel="stylesheet" href="/views/css/components/local-library.css">
<link rel="stylesheet" href="/views/css/components/titlebar.css">
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
<script src="/src/scripts/titlebar.js"></script>
</head>
<body >
<div id="titlebar"><div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<body>
<div id="titlebar">
<div class="title-left">
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
<span class="app-title">WaifuBoard</span>
</div>
<div class="title-right">
<button class="min"></button>
<button class="min"></button>
<button class="max">🗖</button>
<button class="close"></button>
</div>
@@ -121,8 +123,102 @@
</div>
</div>
</div>
<button class="library-mode-btn icon-only" id="library-mode-btn" onclick="toggleLibraryMode()" title="Switch library mode">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<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"/>
</svg>
</button>
</div>
<!-- Online Mode Content -->
<main id="online-content">
<section class="section">
<div class="section-header">
<div class="section-title">Continue watching</div>
</div>
<div class="carousel-wrapper">
<button class="scroll-btn left" onclick="scrollCarousel('my-status', -1)"></button>
<div class="carousel" id="my-status">
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
</div>
<button class="scroll-btn right" onclick="scrollCarousel('my-status', 1)"></button>
</div>
</section>
<section class="section">
<div class="section-header"><div class="section-title">Trending This Season</div></div>
<div class="carousel-wrapper">
<button class="scroll-btn left" onclick="scrollCarousel('trending', -1)"></button>
<div class="carousel" id="trending">
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
</div>
<button class="scroll-btn right" onclick="scrollCarousel('trending', 1)"></button>
</div>
</section>
<section class="section">
<div class="section-header"><div class="section-title">Top Airing Now</div></div>
<div class="carousel-wrapper">
<button class="scroll-btn left" onclick="scrollCarousel('top-airing', -1)"></button>
<div class="carousel" id="top-airing">
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
</div>
<button class="scroll-btn right" onclick="scrollCarousel('top-airing', 1)"></button>
</div>
</section>
</main>
<!-- Local Library Mode Content -->
<main id="local-content" class="hidden">
<section class="section">
<div class="section-header">
<div class="section-title">Local Anime Library</div>
<button class="btn-secondary" onclick="scanLocalLibrary()">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 12a9 9 0 1 1-9-9"/>
<path d="M21 3v6h-6"/>
</svg>
<span id="scan-text">Scan Library</span>
</button>
</div>
<div class="local-filters">
<div class="filter-group">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="watching">Watching</button>
<button class="filter-btn" data-filter="completed">Completed</button>
<button class="filter-btn" data-filter="unwatched">Unwatched</button>
<button class="filter-btn" data-filter="unlinked">Unlinked</button>
</div>
<div class="filter-group">
<button class="filter-btn" data-sort="az">AZ</button>
<button class="filter-btn" data-sort="recent">Recent</button>
</div>
</div>
<div class="local-library-grid" id="local-entries-grid">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
</div>
</section>
</main>
<div class="modal-overlay" id="add-list-modal">
<div class="modal-content modal-list">
<button class="modal-close" onclick="closeAddToListModal()"></button>
@@ -130,11 +226,10 @@
<div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group">
<label>Status</label>
<select id="entry-status" class="form-input">
<option value="WATCHING">Watching/Reading</option>
<option value="WATCHING"><Watchi></Watchi>ng/Reading</option>
<option value="COMPLETED">Completed</option>
<option value="PLANNING">Planning</option>
<option value="PAUSED">Paused</option>
@@ -180,7 +275,6 @@
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div>
@@ -191,63 +285,10 @@
</div>
</div>
</div>
<main>
<section class="section">
<div class="section-header">
<div class="section-title">Continue watching</div>
</div>
<div class="carousel-wrapper">
<button class="scroll-btn left" onclick="scrollCarousel('my-status', -1)"></button>
<div class="carousel" id="my-status">
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
</div>
<button class="scroll-btn right" onclick="scrollCarousel('my-status', 1)"></button>
</div>
</section>
<section class="section">
<div class="section-header"><div class="section-title">Trending This Season</div></div>
<div class="carousel-wrapper">
<button class="scroll-btn left" onclick="scrollCarousel('trending', -1)"></button>
<div class="carousel" id="trending">
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
</div>
<button class="scroll-btn right" onclick="scrollCarousel('trending', 1)"></button>
</div>
</section>
<section class="section">
<div class="section-header"><div class="section-title">Top Airing Now</div></div>
<div class="carousel-wrapper">
<button class="scroll-btn left" onclick="scrollCarousel('top-airing', -1)"></button>
<div class="carousel" id="top-airing">
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
<div class="card"><div class="card-img-wrap skeleton"></div></div>
</div>
<button class="scroll-btn right" onclick="scrollCarousel('top-airing', 1)"></button>
</div>
</section>
</main>
<div id="updateToast" class="hidden">
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
<a
id="downloadButton"
href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases"
target="_blank"
>
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">
Click To Download
</a>
</div>
@@ -258,6 +299,7 @@
<script src="/src/scripts/utils/continue-watching-manager.js"></script>
<script src="/src/scripts/utils/youtube-player-utils.js"></script>
<script src="/src/scripts/anime/animes.js"></script>
<script src="/src/scripts/local-library.js"></script>
<script src="/src/scripts/updateNotifier.js"></script>
<script src="/src/scripts/rpc-inapp.js"></script>
<script src="/src/scripts/auth-guard.js"></script>

View File

@@ -53,8 +53,8 @@
gap: 1.25rem;
}
.info-item h4 { margin: 0 0 0.25rem 0; font-size: 0.85rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
.info-item span { font-weight: 600; font-size: 1rem; color: var(--text-primary); }
.info-item h4 { margin: 0 0 0.25rem 0; font-size: 0.85rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
.info-item span { font-weight: 600; font-size: 1rem; color: var(--color-text-primary); }
.character-list {
display: flex;
@@ -180,7 +180,7 @@
transition: 0.2s;
text-align: center;
font-weight: 600;
color: var(--text-secondary);
color: var(--color-text-secondary);
}
.episode-btn:hover {

View File

@@ -0,0 +1,132 @@
.library-mode-btn {
padding: 0.6rem 1.2rem;
background: rgba(255, 255, 255, 0.05);
border-radius: var(--radius-full);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.2s;
}
.library-mode-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.library-mode-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.library-mode-btn svg {
width: 18px;
height: 18px;
}
.local-library-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
padding: 1rem 0;
}
.local-card {
cursor: pointer;
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.local-card:hover {
transform: translateY(-8px);
}
.local-card-info {
padding: 0.8rem 0;
}
.local-card-title {
font-size: 1rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 0.5rem;
}
.match-status {
font-size: 0.85rem;
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
}
.status-linked {
background: rgba(34, 197, 94, 0.2);
color: var(--color-success);
}
.status-unlinked {
background: rgba(239, 68, 68, 0.2);
color: var(--color-danger);
}
.hidden {
display: none !important;
}
.skeleton-card {
width: 100%;
aspect-ratio: 2/3;
background: linear-gradient(90deg, #18181b 25%, #27272a 50%, #18181b 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-md);
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.hero-wrapper {
position: relative;
}
.library-mode-btn.icon-only {
position: absolute;
bottom: 2rem;
right: 2rem;
z-index: 50;
}
.hero-mode-switch .library-mode-btn {
backdrop-filter: blur(10px);
}
.local-filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.4rem 0.9rem;
border-radius: 999px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
color: #bbb;
cursor: pointer;
}
.filter-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}