From 6075dcf1496f2e9067f3343e436fe9b6ced9bbba Mon Sep 17 00:00:00 2001 From: lenafx Date: Fri, 26 Dec 2025 22:12:36 +0100 Subject: [PATCH] implemented api for local library & global config --- desktop/package-lock.json | 3 +- desktop/package.json | 1 + desktop/server.js | 5 + desktop/src/api/local/local.controller.ts | 252 ++++++++++++++++++++++ desktop/src/api/local/local.routes.ts | 14 ++ desktop/src/api/local/local.service.ts | 0 desktop/src/shared/config.js | 71 ++++++ desktop/src/shared/schemas.js | 51 ++++- 8 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 desktop/src/api/local/local.controller.ts create mode 100644 desktop/src/api/local/local.routes.ts create mode 100644 desktop/src/api/local/local.service.ts create mode 100644 desktop/src/shared/config.js diff --git a/desktop/package-lock.json b/desktop/package-lock.json index fb228f8..202ac24 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -17,6 +17,7 @@ "dotenv": "^17.2.3", "electron-log": "^5.4.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", @@ -1955,7 +1956,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/assert-plus": { @@ -4825,7 +4825,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/desktop/package.json b/desktop/package.json index bc1abba..da03724 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -20,6 +20,7 @@ "dotenv": "^17.2.3", "electron-log": "^5.4.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/desktop/server.js b/desktop/server.js index e97d2d8..f3f9081 100644 --- a/desktop/server.js +++ b/desktop/server.js @@ -8,6 +8,7 @@ const cron = require("node-cron"); const { initHeadless } = require("./electron/shared/headless"); const { initDatabase } = require("./electron/shared/database"); const { loadExtensions } = require("./electron/shared/extensions"); +const { ensureConfigFile } = require("./electron/shared/config"); const { init } = require("./electron/api/rpc/rpc.controller"); const {refreshTrendingAnime, refreshTopAiringAnime} = require("./electron/api/anime/anime.service"); const {refreshPopularBooks, refreshTrendingBooks} = require("./electron/api/books/books.service"); @@ -29,6 +30,7 @@ const rpcRoutes = require("./electron/api/rpc/rpc.routes"); const userRoutes = require("./electron/api/user/user.routes"); const listRoutes = require("./electron/api/list/list.routes"); const anilistRoute = require("./electron/api/anilist/anilist"); +const localRoutes = require("./electron/api/local/local.routes"); fastify.addHook("preHandler", async (request) => { const auth = request.headers.authorization; @@ -70,15 +72,18 @@ fastify.register(rpcRoutes, { prefix: "/api" }); fastify.register(userRoutes, { prefix: "/api" }); fastify.register(anilistRoute, { prefix: "/api" }); fastify.register(listRoutes, { prefix: "/api" }); +fastify.register(localRoutes, { 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"); init(); const refreshAll = async () => { diff --git a/desktop/src/api/local/local.controller.ts b/desktop/src/api/local/local.controller.ts new file mode 100644 index 0000000..6e535c5 --- /dev/null +++ b/desktop/src/api/local/local.controller.ts @@ -0,0 +1,252 @@ +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 getConfig(_request: FastifyRequest, reply: FastifyReply) { + try { + return loadConfig(); + } catch { + return reply.status(500).send({ error: 'FAILED_TO_LOAD_CONFIG' }); + } +} + +export async function setConfig(request: FastifyRequest<{ Body: SetConfigBody }>, reply: FastifyReply) { + try { + const { body } = request; + if (!body || typeof body !== 'object') { + return reply.status(400).send({ error: 'INVALID_BODY' }); + } + return saveConfig(body); + } catch { + return reply.status(500).send({ error: 'FAILED_TO_SAVE_CONFIG' }); + } +} + +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()); + 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)], + 'local_library' + ); + } + } + } + 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); + reply.header('Content-Type', 'video/mp4'); // o dinĂ¡mico + 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; + + 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/desktop/src/api/local/local.routes.ts b/desktop/src/api/local/local.routes.ts new file mode 100644 index 0000000..3cf8eea --- /dev/null +++ b/desktop/src/api/local/local.routes.ts @@ -0,0 +1,14 @@ +import { FastifyInstance } from 'fastify'; +import * as controller from './local.controller'; + +async function localRoutes(fastify: FastifyInstance) { + fastify.get('/library/config', controller.getConfig); + fastify.post('/library/config', controller.setConfig); + 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/desktop/src/api/local/local.service.ts b/desktop/src/api/local/local.service.ts new file mode 100644 index 0000000..e69de29 diff --git a/desktop/src/shared/config.js b/desktop/src/shared/config.js new file mode 100644 index 0000000..b42dd29 --- /dev/null +++ b/desktop/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/desktop/src/shared/schemas.js b/desktop/src/shared/schemas.js index 30676fa..1969786 100644 --- a/desktop/src/shared/schemas.js +++ b/desktop/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