From bc74aa8116d5e810c49f1576fc82310e71550956 Mon Sep 17 00:00:00 2001 From: lenafx Date: Sat, 27 Dec 2025 19:23:22 +0100 Subject: [PATCH] fixed a bug & replicated all changes to docker version --- desktop/src/api/local/local.controller.ts | 26 +- desktop/src/scripts/anime/player.js | 9 +- docker/package-lock.json | 19 + docker/package.json | 1 + docker/server.js | 7 + docker/src/api/config/config.controller.ts | 43 +++ docker/src/api/config/config.routes.ts | 11 + docker/src/api/local/local.controller.ts | 247 ++++++++++++ docker/src/api/local/local.routes.ts | 12 + docker/src/scripts/anime/anime.js | 33 +- docker/src/scripts/anime/player.js | 359 +++++++----------- docker/src/scripts/auth-guard.js | 37 ++ docker/src/scripts/local-library.js | 209 ++++++++++ docker/src/scripts/settings.js | 218 +++++++++++ docker/src/shared/config.js | 71 ++++ docker/src/shared/database.js | 10 +- docker/src/shared/schemas.js | 51 ++- docker/src/views/views.routes.ts | 10 + docker/views/404.html | 139 +++++++ docker/views/anime/anime.html | 1 + docker/views/anime/animes.html | 166 +++++--- docker/views/components/navbar.html | 62 +++ docker/views/components/settings-modal.html | 278 ++++++++++++++ docker/views/css/anime/anime.css | 234 ++---------- docker/views/css/components/local-library.css | 132 +++++++ 25 files changed, 1876 insertions(+), 509 deletions(-) create mode 100644 docker/src/api/config/config.controller.ts create mode 100644 docker/src/api/config/config.routes.ts create mode 100644 docker/src/api/local/local.controller.ts create mode 100644 docker/src/api/local/local.routes.ts create mode 100644 docker/src/scripts/local-library.js create mode 100644 docker/src/scripts/settings.js create mode 100644 docker/src/shared/config.js create mode 100644 docker/views/404.html create mode 100644 docker/views/components/navbar.html create mode 100644 docker/views/components/settings-modal.html create mode 100644 docker/views/css/components/local-library.css diff --git a/desktop/src/api/local/local.controller.ts b/desktop/src/api/local/local.controller.ts index 3c2dad8..5dbb1cc 100644 --- a/desktop/src/api/local/local.controller.ts +++ b/desktop/src/api/local/local.controller.ts @@ -182,14 +182,25 @@ export async function streamUnit(request: FastifyRequest, reply: FastifyReply) { const range = request.headers.range; if (!range) { - reply.header('Content-Length', stat.size); - reply.header('Content-Type', 'video/mp4'); // o dinámico + reply + .header('Content-Length', stat.size) + .header('Content-Type', 'video/mp4'); return fs.createReadStream(file.file_path); } - const [startStr, endStr] = range.replace(/bytes=/, '').split('-'); - const start = parseInt(startStr, 10); - const end = endStr ? parseInt(endStr, 10) : stat.size - 1; + const parts = range.replace(/bytes=/, '').split('-'); + const start = Number(parts[0]); + let end = parts[1] ? Number(parts[1]) : stat.size - 1; + + if ( + Number.isNaN(start) || + Number.isNaN(end) || + start < 0 || + end < start || + end >= stat.size + ) { + end = stat.size - 1; + } reply .status(206) @@ -199,6 +210,7 @@ export async function streamUnit(request: FastifyRequest, reply: FastifyReply) { .header('Content-Type', 'video/mp4'); return fs.createReadStream(file.file_path, { start, end }); + } type MatchBody = { @@ -225,8 +237,8 @@ export async function matchEntry( await run( `UPDATE local_entries - SET matched_source = ?, matched_id = ? - WHERE id = ?`, + SET matched_source = ?, matched_id = ? + WHERE id = ?`, [source, matched_id, id], 'local_library' ); diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index 113a9d8..8f901d0 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -239,8 +239,9 @@ async function loadExtensions() { const select = document.getElementById('extension-select'); let extensions = data.extensions || []; - // Añadimos local manualmente - if (!extensions.includes('local')) extensions.push('local'); + if (extName === 'local' && !extensions.includes('local')) { + extensions.push('local'); + } select.innerHTML = ''; extensions.forEach(ext => { @@ -251,8 +252,8 @@ async function loadExtensions() { if (extName && extensions.includes(extName)) { select.value = extName; - } else { - select.value = 'local'; // Default a local + } else if (extensions.length > 0) { + select.value = extensions[0]; } currentExtension = select.value; diff --git a/docker/package-lock.json b/docker/package-lock.json index 23de5fb..6eb9718 100644 --- a/docker/package-lock.json +++ b/docker/package-lock.json @@ -15,6 +15,7 @@ "cheerio": "^1.1.2", "dotenv": "^17.2.3", "fastify": "^5.6.2", + "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.3", "node-addon-api": "^8.5.0", "node-cron": "^4.2.1", @@ -604,6 +605,12 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1780,6 +1787,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", diff --git a/docker/package.json b/docker/package.json index ebd7bff..910c60b 100644 --- a/docker/package.json +++ b/docker/package.json @@ -18,6 +18,7 @@ "cheerio": "^1.1.2", "dotenv": "^17.2.3", "fastify": "^5.6.2", + "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.3", "node-addon-api": "^8.5.0", "node-cron": "^4.2.1", diff --git a/docker/server.js b/docker/server.js index 9a00c37..0763428 100644 --- a/docker/server.js +++ b/docker/server.js @@ -10,6 +10,7 @@ const { initDatabase } = require("./dist/shared/database"); const { loadExtensions } = require("./dist/shared/extensions"); const {refreshTrendingAnime, refreshTopAiringAnime} = require("./dist/api/anime/anime.service"); const {refreshPopularBooks, refreshTrendingBooks} = require("./dist/api/books/books.service"); +const { ensureConfigFile } = require("./dist/shared/config"); const dotenv = require("dotenv"); dotenv.config(); @@ -23,6 +24,8 @@ const galleryRoutes = require("./dist/api/gallery/gallery.routes"); const userRoutes = require("./dist/api/user/user.routes"); const listRoutes = require("./dist/api/list/list.routes"); const anilistRoute = require("./dist/api/anilist/anilist"); +const localRoutes = require("./dist/api/local/local.routes"); +const configRoutes = require("./dist/api/config/config.routes"); fastify.addHook("preHandler", async (request) => { const auth = request.headers.authorization; @@ -63,15 +66,19 @@ fastify.register(galleryRoutes, { prefix: "/api" }); fastify.register(userRoutes, { prefix: "/api" }); fastify.register(anilistRoute, { prefix: "/api" }); fastify.register(listRoutes, { prefix: "/api" }); +fastify.register(localRoutes, { prefix: "/api" }); +fastify.register(configRoutes, { prefix: "/api" }); const sleep = ms => new Promise(r => setTimeout(r, ms)); const start = async () => { try { + ensureConfigFile() initDatabase("anilist"); initDatabase("favorites"); initDatabase("cache"); initDatabase("userdata"); + initDatabase("local_library"); const refreshAll = async () => { await refreshTrendingAnime(); diff --git a/docker/src/api/config/config.controller.ts b/docker/src/api/config/config.controller.ts new file mode 100644 index 0000000..4788a2b --- /dev/null +++ b/docker/src/api/config/config.controller.ts @@ -0,0 +1,43 @@ +import {FastifyReply, FastifyRequest} from 'fastify'; +import {getConfig, setConfig} from '../../shared/config'; + +export async function getFullConfig(req: FastifyRequest, reply: FastifyReply) { + try { + return getConfig(); + } catch (err) { + return { error: "Error loading config" }; + } +} + +export async function getConfigSection(req: FastifyRequest<{ Params: { section: string } }>, reply: FastifyReply) { + try { + const { section } = req.params; + const config = getConfig(); + + if (config[section] === undefined) { + return { error: "Section not found" }; + } + + return { [section]: config[section] }; + } catch (err) { + return { error: "Error loading config section" }; + } +} + +export async function updateConfig(req: FastifyRequest<{ Body: any }>, reply: FastifyReply) { + try { + return setConfig(req.body); + } catch (err) { + return { error: "Error updating config" }; + } +} + +export async function updateConfigSection(req: FastifyRequest<{ Params: { section: string }, Body: any }>, reply: FastifyReply) { + try { + const { section } = req.params; + const updatedConfig = setConfig({ [section]: req.body }); + return { [section]: updatedConfig[section] }; + } catch (err) { + return { error: "Error updating config section" }; + } +} \ No newline at end of file diff --git a/docker/src/api/config/config.routes.ts b/docker/src/api/config/config.routes.ts new file mode 100644 index 0000000..3be05e7 --- /dev/null +++ b/docker/src/api/config/config.routes.ts @@ -0,0 +1,11 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './config.controller'; + +async function configRoutes(fastify: FastifyInstance) { + fastify.get('/config', controller.getFullConfig); + fastify.get('/config/:section', controller.getConfigSection); + fastify.post('/config', controller.updateConfig); + fastify.post('/config/:section', controller.updateConfigSection); +} + +export default configRoutes; \ No newline at end of file diff --git a/docker/src/api/local/local.controller.ts b/docker/src/api/local/local.controller.ts new file mode 100644 index 0000000..5c66a44 --- /dev/null +++ b/docker/src/api/local/local.controller.ts @@ -0,0 +1,247 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import { getConfig as loadConfig, setConfig as saveConfig } from '../../shared/config.js'; +import { queryOne, queryAll, run } from '../../shared/database.js'; +import crypto from 'crypto'; +import fs from "fs"; +import { PathLike } from "node:fs"; +import path from "path"; +import {getAnimeById, searchAnimeLocal} from "../anime/anime.service"; +import {getBookById, searchBooksLocal} from "../books/books.service"; + +type SetConfigBody = { + library?: { + anime?: string | null; + manga?: string | null; + novels?: string | null; + }; +}; + +type ScanQuery = { + mode?: 'full' | 'incremental'; +}; + +type Params = { + type: 'anime' | 'manga' | 'novels'; + id?: string; +}; + +async function resolveEntryMetadata(entry: any, type: string) { + let metadata = null; + let matchedId = entry.matched_id; + + if (!matchedId) { + const query = entry.folder_name; + + const results = type === 'anime' + ? await searchAnimeLocal(query) + : await searchBooksLocal(query); + + const first = results?.[0]; + + if (first?.id) { + matchedId = first.id; + + await run( + `UPDATE local_entries + SET matched_id = ?, matched_source = 'anilist' + WHERE id = ?`, + [matchedId, entry.id], + 'local_library' + ); + } + } + + if (matchedId) { + metadata = type === 'anime' + ? await getAnimeById(matchedId) + : await getBookById(matchedId); + } + + return { + id: entry.id, + type: entry.type, + matched: !!matchedId, + metadata + }; +} + +export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQuery }>, reply: FastifyReply) { + try { + const mode = request.query.mode || 'incremental'; + const config = loadConfig(); + + if (!config.library) { + return reply.status(400).send({ error: 'NO_LIBRARY_CONFIGURED' }); + } + + if (mode === 'full') { + await run(`DELETE FROM local_files`, [], 'local_library'); + await run(`DELETE FROM local_entries`, [], 'local_library'); + } + + for (const [type, basePath] of Object.entries(config.library)) { + if (!basePath || !fs.existsSync(basePath)) continue; + + const dirs = fs.readdirSync(basePath, { withFileTypes: true }).filter(d => d.isDirectory()); + + for (const dir of dirs) { + const fullPath = path.join(basePath, dir.name); + const id = crypto.createHash('sha1').update(fullPath).digest('hex'); + const now = Date.now(); + + const existing = await queryOne(`SELECT id FROM local_entries WHERE id = ?`, [id], 'local_library'); + + if (existing) { + await run(`UPDATE local_entries SET last_scan = ? WHERE id = ?`, [now, id], 'local_library'); + await run(`DELETE FROM local_files WHERE entry_id = ?`, [id], 'local_library'); + } else { + await run( + `INSERT INTO local_entries (id, type, path, folder_name, last_scan) VALUES (?, ?, ?, ?, ?)`, + [id, type, fullPath, dir.name, now], + 'local_library' + ); + } + + 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, unit_number) + VALUES (?, ?, ?, ?)`, + [crypto.randomUUID(), id, path.join(fullPath, file.name), unit], + 'local_library' + ); + unit++; + } + } + } + return { status: 'OK' }; + } catch (err) { + return reply.status(500).send({ error: 'FAILED_TO_SCAN_LIBRARY' }); + } +} + +export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { + try { + const { type } = request.params; + const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library'); + + return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type))); + } catch { + return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' }); + } +} + +export async function getEntry(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) { + try { + const { type, id } = request.params as { type: string, id: string }; + + const entry = await queryOne( + `SELECT * FROM local_entries WHERE matched_id = ? AND type = ?`, + [Number(id), type], + 'local_library' + ); + + if (!entry) { + return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); + } + + const [details, files] = await Promise.all([ + resolveEntryMetadata(entry, type), + queryAll( + `SELECT id, file_path, unit_number FROM local_files WHERE entry_id = ? ORDER BY unit_number ASC`, + [id], + 'local_library' + ) + ]); + + return { ...details, files }; + } catch { + return reply.status(500).send({ error: 'FAILED_TO_GET_ENTRY' }); + } +} + +export async function streamUnit(request: FastifyRequest, reply: FastifyReply) { + const { id, unit } = request.params as any; + + const file = await queryOne( + `SELECT file_path FROM local_files WHERE entry_id = ? AND unit_number = ?`, + [id, unit], + 'local_library' + ); + + if (!file || !fs.existsSync(file.file_path)) { + return reply.status(404).send({ error: 'FILE_NOT_FOUND' }); + } + + const stat = fs.statSync(file.file_path); + const range = request.headers.range; + + if (!range) { + reply + .header('Content-Length', stat.size) + .header('Content-Type', 'video/mp4'); + return fs.createReadStream(file.file_path); + } + + const parts = range.replace(/bytes=/, '').split('-'); + const start = Number(parts[0]); + let end = parts[1] ? Number(parts[1]) : stat.size - 1; + + if ( + Number.isNaN(start) || + Number.isNaN(end) || + start < 0 || + end < start || + end >= stat.size + ) { + end = stat.size - 1; + } + + reply + .status(206) + .header('Content-Range', `bytes ${start}-${end}/${stat.size}`) + .header('Accept-Ranges', 'bytes') + .header('Content-Length', end - start + 1) + .header('Content-Type', 'video/mp4'); + + return fs.createReadStream(file.file_path, { start, end }); + +} + +type MatchBody = { + source: 'anilist'; + matched_id: number | null; +}; + +export async function matchEntry( + request: FastifyRequest<{ Body: MatchBody }>, + reply: FastifyReply +) { + const { id, type } = request.params as any; + const { source, matched_id } = request.body; + + const entry = await queryOne( + `SELECT id FROM local_entries WHERE id = ? AND type = ?`, + [id, type], + 'local_library' + ); + + if (!entry) { + return reply.status(404).send({ error: 'ENTRY_NOT_FOUND' }); + } + + await run( + `UPDATE local_entries + SET matched_source = ?, matched_id = ? + WHERE id = ?`, + [source, matched_id, id], + 'local_library' + ); + + return { status: 'OK', matched: !!matched_id }; +} diff --git a/docker/src/api/local/local.routes.ts b/docker/src/api/local/local.routes.ts new file mode 100644 index 0000000..5924812 --- /dev/null +++ b/docker/src/api/local/local.routes.ts @@ -0,0 +1,12 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './local.controller'; + +async function localRoutes(fastify: FastifyInstance) { + fastify.post('/library/scan', controller.scanLibrary); + fastify.get('/library/:type', controller.listEntries); + fastify.get('/library/:type/:id', controller.getEntry); + fastify.get('/library/stream/:type/:id/:unit', controller.streamUnit); + fastify.post('/library/:type/:id/match', controller.matchEntry); +} + +export default localRoutes; \ No newline at end of file diff --git a/docker/src/scripts/anime/anime.js b/docker/src/scripts/anime/anime.js index f605e3c..673562d 100644 --- a/docker/src/scripts/anime/anime.js +++ b/docker/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,29 @@ 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 +48,7 @@ async function loadAnime() { extensionName = urlData.extensionName; animeId = urlData.entityId; + await checkLocalLibraryEntry(); const fetchUrl = extensionName ? `/api/anime/${animeId}?source=${extensionName}` @@ -142,8 +167,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 +251,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/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js index 65a3b13..da56b29 100644 --- a/docker/src/scripts/anime/player.js +++ b/docker/src/scripts/anime/player.js @@ -7,6 +7,7 @@ let currentExtension = ''; let plyrInstance; let hlsInstance; let totalEpisodes = 0; +let animeTitle = ""; let aniSkipData = null; let isAnilist = false; @@ -17,13 +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; + + } 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'; @@ -85,6 +96,7 @@ async function loadMetadata() { document.getElementById('anime-title-details').innerText = title; document.getElementById('anime-title-details2').innerText = title; + animeTitle = title; document.title = `Watching ${title} - Ep ${currentEpisode}`; const tempDiv = document.createElement('div'); @@ -96,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) { @@ -108,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); } @@ -128,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'; @@ -215,56 +181,36 @@ 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); } } function populateEpisodeCarousel(episodesData) { const carousel = document.getElementById('episode-carousel'); - if (!carousel) return; - carousel.innerHTML = ''; episodesData.forEach((ep, index) => { 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,27 @@ 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); - }); - - if (typeof extName === 'string' && data.extensions.includes(extName)) { - select.value = extName; - } else { - select.selectedIndex = 0; - } - - currentExtension = select.value; - onExtensionChange(); - } else { - select.innerHTML = ''; - select.disabled = true; - setLoading("No anime extensions found."); + if (extName === 'local' && !extensions.includes('local')) { + extensions.push('local'); } + + select.innerHTML = ''; + extensions.forEach(ext => { + const opt = document.createElement('option'); + opt.value = opt.innerText = ext; + select.appendChild(opt); + }); + + if (extName && extensions.includes(extName)) { + select.value = extName; + } else if (extensions.length > 0) { + select.value = extensions[0]; + } + + currentExtension = select.value; + onExtensionChange(); } catch (error) { console.error("Extension Error:", error); } @@ -327,83 +266,69 @@ 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') { + 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,33 +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(); - - const existingTracks = video.querySelectorAll('track'); - existingTracks.forEach(track => track.remove()); + 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'; @@ -454,74 +377,23 @@ 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); - }); - - let alreadyTriggered = false; - - video.addEventListener('timeupdate', () => { - if (!video.duration) return; - - const percent = (video.currentTime / video.duration) * 100; - - if (percent >= 80 && !alreadyTriggered) { - alreadyTriggered = true; - sendProgress(); - } - }); - - video.play().catch(() => console.log("Autoplay blocked")); -} - -function setLoading(message) { - const overlay = document.getElementById('loading-overlay'); - const text = document.getElementById('loading-text'); - overlay.style.display = 'flex'; - text.innerText = message; -} - -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; + video.addEventListener('loadedmetadata', () => applyAniSkip(video)); } 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 { @@ -538,5 +410,38 @@ 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; + +setInterval(() => { + if (plyrInstance && !plyrInstance.paused) sendProgress(); +}, 60000); + loadMetadata(); loadExtensions(); \ No newline at end of file diff --git a/docker/src/scripts/auth-guard.js b/docker/src/scripts/auth-guard.js index ae6a220..e0cfd85 100644 --- a/docker/src/scripts/auth-guard.js +++ b/docker/src/scripts/auth-guard.js @@ -43,6 +43,43 @@ async function loadMeUI() { } } +// Variable para saber si el modal ya fue cargado +let settingsModalLoaded = false; + +document.getElementById('nav-settings').addEventListener('click', openSettings) + +async function openSettings() { + if (!settingsModalLoaded) { + try { + const res = await fetch('/views/components/settings-modal.html') + const html = await res.text() + document.body.insertAdjacentHTML('beforeend', html) + settingsModalLoaded = true; + + // Esperar un momento para que el DOM se actualice + await new Promise(resolve => setTimeout(resolve, 50)); + + // Ahora cargar los settings + if (window.toggleSettingsModal) { + await window.toggleSettingsModal(false); + } + } catch (err) { + console.error('Error loading settings modal:', err); + } + } else { + if (window.toggleSettingsModal) { + await window.toggleSettingsModal(false); + } + } +} + +function closeSettings() { + const modal = document.getElementById('settings-modal'); + if (modal) { + modal.classList.add('hidden'); + } +} + function setupDropdown() { const userAvatarBtn = document.querySelector(".user-avatar-btn") const navDropdown = document.getElementById("nav-dropdown") diff --git a/docker/src/scripts/local-library.js b/docker/src/scripts/local-library.js new file mode 100644 index 0000000..76c89aa --- /dev/null +++ b/docker/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 = ` + + + `; + } else { + // ONLINE MODE + btn.classList.remove('active'); + onlineContent.classList.remove('hidden'); + localContent.classList.add('hidden'); + + svg.innerHTML = ` + + + + `; + } +} + +async function loadLocalEntries() { + const grid = document.getElementById('local-entries-grid'); + grid.innerHTML = '
'.repeat(8); + + try { + const response = await fetch('/api/library/anime'); + const entries = await response.json(); + localEntries = entries; + + if (entries.length === 0) { + grid.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 ` +
+
+ ${title} +
+
+
${title}
+

+ ${score}% • ${episodes} Eps +

+
+ ${entry.matched ? '● Linked' : '○ Unlinked'} +
+
+
+ `; + }).join(''); + } catch (err) { + console.error('Error loading local entries:', err); + grid.innerHTML = '

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 ` +
+
+ ${title} +
+
+
${title}
+

+ ${score}% • ${episodes} Eps +

+
+ ${entry.matched ? '● Linked' : '○ Unlinked'} +
+
+
+ `; + }).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(); +}); diff --git a/docker/src/scripts/settings.js b/docker/src/scripts/settings.js new file mode 100644 index 0000000..d1bb295 --- /dev/null +++ b/docker/src/scripts/settings.js @@ -0,0 +1,218 @@ +const API_BASE = '/api/config'; +let currentConfig = {}; +let activeSection = ''; +let modal, navContainer, formContent, form; + +window.toggleSettingsModal = async (forceClose = false) => { + modal = document.getElementById('settings-modal'); + navContainer = document.getElementById('config-nav'); + formContent = document.getElementById('config-section-content'); + form = document.getElementById('config-form'); + + if (!modal) { + console.error('Modal not found'); + return; + } + + if (forceClose) { + modal.classList.add('hidden'); + } else { + const isHidden = modal.classList.contains('hidden'); + + if (isHidden) { + // Abrir modal + modal.classList.remove('hidden'); + await loadSettings(); + } else { + // Cerrar modal + modal.classList.add('hidden'); + } + } +}; + +async function loadSettings() { + if (!formContent) { + console.error('Form content not found'); + return; + } + + // Mostrar loading + formContent.innerHTML = ` +
+
+
+
+
+ `; + + try { + const res = await fetch(API_BASE); + + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + + const data = await res.json(); + + if (data.error) throw new Error(data.error); + + currentConfig = data; + renderNav(); + + // Seleccionar la primera sección si no hay ninguna activa + if (!activeSection || !currentConfig[activeSection]) { + activeSection = Object.keys(currentConfig)[0]; + } + + switchSection(activeSection); + } catch (err) { + console.error('Error loading settings:', err); + formContent.innerHTML = ` +
+

Failed to load settings

+

${err.message}

+
+ `; + } +} + +function renderNav() { + if (!navContainer) return; + + navContainer.innerHTML = ''; + Object.keys(currentConfig).forEach(section => { + const btn = document.createElement('div'); + btn.className = `nav-item ${section === activeSection ? 'active' : ''}`; + btn.textContent = section; + btn.onclick = () => switchSection(section); + navContainer.appendChild(btn); + }); +} + +function switchSection(section) { + if (!currentConfig[section]) return; + + activeSection = section; + renderNav(); + + const sectionData = currentConfig[section]; + + formContent.innerHTML = ` +

+ ${section.replace(/_/g, ' ')} +

+ `; + + Object.entries(sectionData).forEach(([key, value]) => { + const group = document.createElement('div'); + group.className = 'config-group'; + + const isBool = typeof value === 'boolean'; + const inputId = `input-${section}-${key}`; + const label = key.replace(/_/g, ' '); + + if (isBool) { + group.innerHTML = ` +
+ + +
+ `; + } else { + group.innerHTML = ` + + + `; + } + + formContent.appendChild(group); + }); +} + +// Setup form submit handler +document.addEventListener('DOMContentLoaded', () => { + // Usar delegación de eventos ya que el form se carga dinámicamente + document.addEventListener('submit', async (e) => { + if (e.target.id === 'config-form') { + e.preventDefault(); + await saveSettings(); + } + }); +}); + +async function saveSettings() { + if (!form || !activeSection) return; + + const updatedData = {}; + + Object.keys(currentConfig[activeSection]).forEach(key => { + const input = form.elements[key]; + if (!input) return; + + if (input.type === 'checkbox') { + updatedData[key] = input.checked; + } else if (input.type === 'number') { + updatedData[key] = Number(input.value); + } else { + updatedData[key] = input.value; + } + }); + + try { + const res = await fetch(`${API_BASE}/${activeSection}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedData) + }); + + if (res.ok) { + currentConfig[activeSection] = updatedData; + + // Mostrar notificación de éxito + const notification = document.createElement('div'); + notification.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: var(--color-success, #10b981); + color: white; + padding: 1rem 1.5rem; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 10000; + animation: slideIn 0.3s ease-out; + `; + notification.textContent = 'Settings saved successfully!'; + document.body.appendChild(notification); + + setTimeout(() => { + notification.style.animation = 'slideOut 0.3s ease-out'; + setTimeout(() => notification.remove(), 300); + }, 2000); + } else { + throw new Error('Failed to save settings'); + } + } catch (err) { + console.error('Error saving settings:', err); + alert('Error saving settings: ' + err.message); + } +} + +// Añadir estilos para las animaciones (solo si no existen) +if (!document.getElementById('settings-animations')) { + const animationStyles = document.createElement('style'); + animationStyles.id = 'settings-animations'; + animationStyles.textContent = ` + @keyframes slideIn { + from { transform: translateX(400px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + @keyframes slideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(400px); opacity: 0; } + } + `; + document.head.appendChild(animationStyles); +} \ No newline at end of file diff --git a/docker/src/shared/config.js b/docker/src/shared/config.js new file mode 100644 index 0000000..b42dd29 --- /dev/null +++ b/docker/src/shared/config.js @@ -0,0 +1,71 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import yaml from 'js-yaml'; + +const BASE_DIR = path.join(os.homedir(), 'WaifuBoards'); +const CONFIG_PATH = path.join(BASE_DIR, 'config.yaml'); + +const DEFAULT_CONFIG = { + library: { + anime: null, + manga: null, + novels: null + } +}; + +function ensureConfigFile() { + if (!fs.existsSync(BASE_DIR)) { + fs.mkdirSync(BASE_DIR, { recursive: true }); + } + + if (!fs.existsSync(CONFIG_PATH)) { + fs.writeFileSync( + CONFIG_PATH, + yaml.dump(DEFAULT_CONFIG), + 'utf8' + ); + } +} + +export function getConfig() { + ensureConfigFile(); + const raw = fs.readFileSync(CONFIG_PATH, 'utf8'); + return yaml.load(raw) || DEFAULT_CONFIG; +} + +export function setConfig(partialConfig) { + ensureConfigFile(); + + const current = getConfig(); + const next = deepMerge(current, partialConfig); + + fs.writeFileSync( + CONFIG_PATH, + yaml.dump(next), + 'utf8' + ); + + return next; +} + +function deepMerge(target, source) { + for (const key in source) { + if ( + source[key] && + typeof source[key] === 'object' && + !Array.isArray(source[key]) + ) { + target[key] = deepMerge(target[key] || {}, source[key]); + } else { + target[key] = source[key]; + } + } + return target; +} + +module.exports = { + ensureConfigFile, + getConfig, + setConfig, +}; \ No newline at end of file diff --git a/docker/src/shared/database.js b/docker/src/shared/database.js index bfcc1ce..725965a 100644 --- a/docker/src/shared/database.js +++ b/docker/src/shared/database.js @@ -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)) diff --git a/docker/src/shared/schemas.js b/docker/src/shared/schemas.js index 30676fa..1969786 100644 --- a/docker/src/shared/schemas.js +++ b/docker/src/shared/schemas.js @@ -2,6 +2,54 @@ const sqlite3 = require('sqlite3').verbose(); const path = require("path"); const fs = require("fs"); +async function ensureLocalLibrarySchema(db) { + await run(db, ` + CREATE TABLE IF NOT EXISTS local_entries ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + path TEXT NOT NULL, + folder_name TEXT NOT NULL, + matched_id INTEGER, + matched_source TEXT, + last_scan INTEGER NOT NULL + ) + `); + + await run(db, ` + CREATE TABLE IF NOT EXISTS local_files ( + id TEXT PRIMARY KEY, + entry_id TEXT NOT NULL, + file_path TEXT NOT NULL, + unit_number INTEGER, + FOREIGN KEY (entry_id) REFERENCES local_entries(id) + ) + `); + + await run(db, ` + CREATE INDEX IF NOT EXISTS idx_local_entries_type + ON local_entries(type) + `); + + await run(db, ` + CREATE INDEX IF NOT EXISTS idx_local_entries_matched + ON local_entries(matched_id) + `); + + await run(db, ` + CREATE INDEX IF NOT EXISTS idx_local_files_entry + ON local_files(entry_id) + `); +} + +function run(db, sql, params = []) { + return new Promise((resolve, reject) => { + db.run(sql, params, err => { + if (err) reject(err); + else resolve(); + }); + }); +} + async function ensureUserDataDB(dbPath) { const dir = path.dirname(dbPath); @@ -230,5 +278,6 @@ module.exports = { ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, - ensureFavoritesDB + ensureFavoritesDB, + ensureLocalLibrarySchema }; \ No newline at end of file diff --git a/docker/src/views/views.routes.ts b/docker/src/views/views.routes.ts index 5548636..96d78ae 100644 --- a/docker/src/views/views.routes.ts +++ b/docker/src/views/views.routes.ts @@ -77,6 +77,16 @@ async function viewsRoutes(fastify: FastifyInstance) { const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'read.html')); reply.type('text/html').send(stream); }); + + fastify.setNotFoundHandler((req, reply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', '404.html')); + + + reply + .code(404) + .type('text/html') + .send(stream) + }) } export default viewsRoutes; \ No newline at end of file diff --git a/docker/views/404.html b/docker/views/404.html new file mode 100644 index 0000000..f3775dd --- /dev/null +++ b/docker/views/404.html @@ -0,0 +1,139 @@ + + + + + + 404 - WaifuBoard + + + + + + + + + + + +
+
+ + WaifuBoard +
+
+ + + +
+
+ + + +
+
+

404

+

+ This page doesn’t exist. +

+ +
+ + +
+
+
+ + + + + \ No newline at end of file diff --git a/docker/views/anime/anime.html b/docker/views/anime/anime.html index 051c71a..e1a05df 100644 --- a/docker/views/anime/anime.html +++ b/docker/views/anime/anime.html @@ -219,6 +219,7 @@ id="extension-pill" style="display: none; background: #8b5cf6" > +
--% Score
----
Action
diff --git a/docker/views/anime/animes.html b/docker/views/anime/animes.html index 78b3c5c..b69f2a0 100644 --- a/docker/views/anime/animes.html +++ b/docker/views/anime/animes.html @@ -9,9 +9,10 @@ + - +