organisation & minor fixes
This commit is contained in:
120
src/api/anime/anime.controller.ts
Normal file
120
src/api/anime/anime.controller.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {FastifyReply, FastifyRequest} from 'fastify';
|
||||
import * as animeService from './anime.service';
|
||||
import {getExtension} from '../../shared/extensions';
|
||||
import {Anime, AnimeRequest, SearchRequest, WatchStreamRequest} from '../types';
|
||||
|
||||
export async function getAnime(req: AnimeRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const source = req.query.ext || 'anilist';
|
||||
|
||||
let anime: Anime | { error: string };
|
||||
if (source === 'anilist') {
|
||||
anime = await animeService.getAnimeById(id);
|
||||
} else {
|
||||
const ext = getExtension(source);
|
||||
anime = await animeService.getAnimeInfoExtension(ext, id)
|
||||
}
|
||||
|
||||
return anime;
|
||||
} catch (err) {
|
||||
return { error: "Database error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAnimeEpisodes(req: AnimeRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const extensionName = req.query.ext || 'anilist';
|
||||
const ext = getExtension(extensionName);
|
||||
|
||||
return await animeService.searchEpisodesInExtension(
|
||||
ext,
|
||||
extensionName,
|
||||
id
|
||||
);
|
||||
} catch (err) {
|
||||
return { error: "Database error" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrending(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const results = await animeService.getTrendingAnime();
|
||||
return { results };
|
||||
} catch (err) {
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTopAiring(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const results = await animeService.getTopAiringAnime();
|
||||
return { results };
|
||||
} catch (err) {
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function search(req: SearchRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const query = req.query.q;
|
||||
const results = await animeService.searchAnimeLocal(query);
|
||||
|
||||
if (results.length > 0) {
|
||||
return { results: results };
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchInExtension(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const extensionName = req.params.extension;
|
||||
const query = req.query.q;
|
||||
|
||||
const ext = getExtension(extensionName);
|
||||
if (!ext) return { results: [] };
|
||||
|
||||
const results = await animeService.searchAnimeInExtension(ext, extensionName, query);
|
||||
return { results };
|
||||
} catch {
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWatchStream(req: WatchStreamRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { animeId, episode, server, category, ext } = req.query;
|
||||
|
||||
const extension = getExtension(ext);
|
||||
if (!extension) return { error: "Extension not found" };
|
||||
|
||||
let anime: Anime | { error: string };
|
||||
if (!isNaN(Number(animeId))) {
|
||||
anime = await animeService.getAnimeById(animeId);
|
||||
if ('error' in anime) return { error: "Anime metadata not found" };
|
||||
} else {
|
||||
const results = await animeService.searchAnimeInExtension(
|
||||
extension,
|
||||
ext,
|
||||
animeId.replace(/--/g, '\u0000').replace(/-/g, ' ').replace(new RegExp('\u0000', 'g'), '-').trim()
|
||||
);
|
||||
anime = results[0];
|
||||
if (!anime) return { error: "Anime not found in extension search" };
|
||||
}
|
||||
|
||||
return await animeService.getStreamData(
|
||||
extension,
|
||||
anime,
|
||||
episode,
|
||||
server,
|
||||
category
|
||||
);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
14
src/api/anime/anime.routes.ts
Normal file
14
src/api/anime/anime.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './anime.controller';
|
||||
|
||||
async function animeRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/anime/:id', controller.getAnime);
|
||||
fastify.get('/anime/:id/:episodes', controller.getAnimeEpisodes);
|
||||
fastify.get('/trending', controller.getTrending);
|
||||
fastify.get('/top-airing', controller.getTopAiring);
|
||||
fastify.get('/search', controller.search);
|
||||
fastify.get('/search/:extension', controller.searchInExtension);
|
||||
fastify.get('/watch/stream', controller.getWatchStream);
|
||||
}
|
||||
|
||||
export default animeRoutes;
|
||||
246
src/api/anime/anime.service.ts
Normal file
246
src/api/anime/anime.service.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { getCache, setCache, getCachedExtension, cacheExtension, getExtensionTitle } from '../../shared/queries';
|
||||
import { queryAll, queryOne } from '../../shared/database';
|
||||
import {Anime, Episode, Extension, StreamData} from '../types';
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function getAnimeById(id: string | number): Promise<Anime | { error: string }> {
|
||||
const row = await queryOne("SELECT full_data FROM anime WHERE id = ?", [id]);
|
||||
|
||||
if (!row) {
|
||||
return { error: "Anime not found" };
|
||||
}
|
||||
|
||||
return JSON.parse(row.full_data);
|
||||
}
|
||||
|
||||
export async function getTrendingAnime(): Promise<Anime[]> {
|
||||
const rows = await queryAll("SELECT full_data FROM trending ORDER BY rank ASC LIMIT 10");
|
||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
||||
}
|
||||
|
||||
export async function getTopAiringAnime(): Promise<Anime[]> {
|
||||
const rows = await queryAll("SELECT full_data FROM top_airing ORDER BY rank ASC LIMIT 10");
|
||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
||||
}
|
||||
|
||||
export async function searchAnimeLocal(query: string): Promise<Anime[]> {
|
||||
if (!query || query.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sql = `SELECT full_data FROM anime WHERE full_data LIKE ? LIMIT 50`;
|
||||
const rows = await queryAll(sql, [`%${query}%`]);
|
||||
|
||||
const results: Anime[] = rows.map((row: { full_data: string; }) => JSON.parse(row.full_data));
|
||||
|
||||
const cleanResults = results.filter(anime => {
|
||||
const q = query.toLowerCase();
|
||||
const titles = [
|
||||
anime.title.english,
|
||||
anime.title.romaji,
|
||||
anime.title.native,
|
||||
...(anime.synonyms || [])
|
||||
].filter(Boolean).map(t => t!.toLowerCase());
|
||||
|
||||
return titles.some(t => t.includes(q));
|
||||
});
|
||||
|
||||
return cleanResults.slice(0, 10);
|
||||
}
|
||||
|
||||
export async function getAnimeInfoExtension(ext: Extension | null, id: string): Promise<Anime | { error: string }> {
|
||||
if (!ext) return { error: "not found" };
|
||||
|
||||
const extName = ext.constructor.name;
|
||||
|
||||
const cached = await getCachedExtension(extName, id);
|
||||
if (cached) {
|
||||
try {
|
||||
console.log(`[${extName}] Metadata cache hit for ID: ${id}`);
|
||||
return JSON.parse(cached.metadata) as Anime;
|
||||
} catch {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if ((ext.type === 'anime-board') && ext.getMetadata) {
|
||||
try {
|
||||
const match = await ext.getMetadata(id);
|
||||
|
||||
if (match) {
|
||||
|
||||
await cacheExtension(extName, id, match.title, match);
|
||||
return match;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Extension getMetadata failed:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "not found" };
|
||||
}
|
||||
|
||||
export async function searchAnimeInExtension(ext: Extension | null, name: string, query: string): Promise<Anime[]> {
|
||||
if (!ext) return [];
|
||||
|
||||
if (ext.type === 'anime-board' && ext.search) {
|
||||
try {
|
||||
console.log(`[${name}] Searching for anime: ${query}`);
|
||||
const matches = await ext.search({
|
||||
query: query,
|
||||
media: {
|
||||
romajiTitle: query,
|
||||
englishTitle: query,
|
||||
startDate: { year: 0, month: 0, day: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
if (matches && matches.length > 0) {
|
||||
return matches.map(m => ({
|
||||
id: m.id,
|
||||
extensionName: name,
|
||||
title: { romaji: m.title, english: m.title, native: null },
|
||||
coverImage: { large: m.image || '' },
|
||||
averageScore: m.rating || m.score || null,
|
||||
format: 'ANIME',
|
||||
seasonYear: null,
|
||||
isExtensionResult: true,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Extension search failed for ${name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function searchEpisodesInExtension(ext: Extension | null, name: string, query: string): Promise<Episode[]> {
|
||||
if (!ext) return [];
|
||||
|
||||
const cacheKey = `anime:episodes:${name}:${query}`;
|
||||
const cached = await getCache(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${name}] Episodes cache hit for: ${query}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as Episode[];
|
||||
} catch (e) {
|
||||
console.error(`[${name}] Error parsing cached episodes:`, e);
|
||||
}
|
||||
} else {
|
||||
console.log(`[${name}] Episodes cache expired for: ${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (ext.type === "anime-board" && ext.search && typeof ext.findEpisodes === "function") {
|
||||
try {
|
||||
const title = await getExtensionTitle(name, query);
|
||||
let mediaId: string;
|
||||
|
||||
if (title) {
|
||||
|
||||
const matches = await ext.search({
|
||||
query,
|
||||
media: {
|
||||
romajiTitle: query,
|
||||
englishTitle: query,
|
||||
startDate: { year: 0, month: 0, day: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
if (!matches || matches.length === 0) return [];
|
||||
|
||||
const res = matches[0];
|
||||
if (!res?.id) return [];
|
||||
|
||||
mediaId = res.id;
|
||||
|
||||
} else {
|
||||
|
||||
mediaId = query;
|
||||
}
|
||||
|
||||
const chapterList = await ext.findEpisodes(mediaId);
|
||||
|
||||
if (!Array.isArray(chapterList)) return [];
|
||||
|
||||
const result: Episode[] = chapterList.map(ep => ({
|
||||
id: ep.id,
|
||||
number: ep.number,
|
||||
url: ep.url,
|
||||
title: ep.title
|
||||
}));
|
||||
|
||||
await setCache(cacheKey, result, CACHE_TTL_MS);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (e) {
|
||||
console.error(`Extension search failed for ${name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function getStreamData(extension: Extension, animeData: Anime, episode: string, server?: string, category?: string): Promise<StreamData> {
|
||||
const providerName = extension.constructor.name;
|
||||
|
||||
const cacheKey = `anime:stream:${providerName}:${animeData.id}:${episode}:${server || 'default'}:${category || 'sub'}`;
|
||||
|
||||
const cached = await getCache(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${providerName}] Stream data cache hit for episode ${episode}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as StreamData;
|
||||
} catch (e) {
|
||||
console.error(`[${providerName}] Error parsing cached stream data:`, e);
|
||||
}
|
||||
} else {
|
||||
console.log(`[${providerName}] Stream data cache expired for episode ${episode}`);
|
||||
}
|
||||
}
|
||||
|
||||
const searchOptions = {
|
||||
query: animeData.title.english || animeData.title.romaji,
|
||||
dub: category === 'dub',
|
||||
media: {
|
||||
romajiTitle: animeData.title.romaji,
|
||||
englishTitle: animeData.title.english || "",
|
||||
startDate: animeData.startDate || { year: 0, month: 0, day: 0 }
|
||||
}
|
||||
};
|
||||
|
||||
if (!extension.search || !extension.findEpisodes || !extension.findEpisodeServer) {
|
||||
throw new Error("Extension doesn't support required methods");
|
||||
}
|
||||
|
||||
const searchResults = await extension.search(searchOptions);
|
||||
|
||||
if (!searchResults || searchResults.length === 0) {
|
||||
throw new Error("Anime not found on provider");
|
||||
}
|
||||
|
||||
const bestMatch = searchResults[0];
|
||||
const episodes = await extension.findEpisodes(bestMatch.id);
|
||||
const targetEp = episodes.find(e => e.number === parseInt(episode));
|
||||
|
||||
if (!targetEp) {
|
||||
throw new Error("Episode not found");
|
||||
}
|
||||
|
||||
const serverName = server || "default";
|
||||
const streamData = await extension.findEpisodeServer(targetEp, serverName);
|
||||
|
||||
await setCache(cacheKey, streamData, CACHE_TTL_MS);
|
||||
return streamData;
|
||||
}
|
||||
114
src/api/books/books.controller.ts
Normal file
114
src/api/books/books.controller.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {FastifyReply, FastifyRequest} from 'fastify';
|
||||
import * as booksService from './books.service';
|
||||
import {getExtension} from '../../shared/extensions';
|
||||
import {BookRequest, ChapterRequest, SearchRequest} from '../types';
|
||||
|
||||
export async function getBook(req: BookRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const source = req.query.ext || 'anilist';
|
||||
|
||||
let book;
|
||||
if (source === 'anilist') {
|
||||
book = await booksService.getBookById(id);
|
||||
} else {
|
||||
const ext = getExtension(source);
|
||||
|
||||
const result = await booksService.getBookInfoExtension(ext, id);
|
||||
book = result || null;
|
||||
}
|
||||
|
||||
return book;
|
||||
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
return { error: error.toString() };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrending(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const results = await booksService.getTrendingBooks();
|
||||
return { results };
|
||||
} catch (err) {
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPopular(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const results = await booksService.getPopularBooks();
|
||||
return { results };
|
||||
} catch (err) {
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchBooks(req: SearchRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const query = req.query.q;
|
||||
|
||||
const dbResults = await booksService.searchBooksLocal(query);
|
||||
if (dbResults.length > 0) {
|
||||
return { results: dbResults };
|
||||
}
|
||||
|
||||
console.log(`[Books] Local DB miss for "${query}", fetching live...`);
|
||||
const anilistResults = await booksService.searchBooksAniList(query);
|
||||
if (anilistResults.length > 0) {
|
||||
return { results: anilistResults };
|
||||
}
|
||||
|
||||
return { results: [] };
|
||||
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error("Search Error:", error.message);
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchBooksInExtension(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const extensionName = req.params.extension;
|
||||
const query = req.query.q;
|
||||
|
||||
const ext = getExtension(extensionName);
|
||||
if (!ext) return { results: [] };
|
||||
|
||||
const results = await booksService.searchBooksInExtension(ext, extensionName, query);
|
||||
return { results };
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error("Search Error:", error.message);
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChapters(req: BookRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
return await booksService.getChaptersForBook(id);
|
||||
} catch (err) {
|
||||
return { chapters: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChapterContent(req: ChapterRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { bookId, chapter, provider } = req.params;
|
||||
|
||||
const content = await booksService.getChapterContent(
|
||||
bookId,
|
||||
chapter,
|
||||
provider
|
||||
);
|
||||
|
||||
return reply.send(content);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error("getChapterContent error:", error.message);
|
||||
|
||||
return reply.code(500).send({ error: "Error loading chapter" });
|
||||
}
|
||||
}
|
||||
14
src/api/books/books.routes.ts
Normal file
14
src/api/books/books.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './books.controller';
|
||||
|
||||
async function booksRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/book/:id', controller.getBook);
|
||||
fastify.get('/books/trending', controller.getTrending);
|
||||
fastify.get('/books/popular', controller.getPopular);
|
||||
fastify.get('/search/books', controller.searchBooks);
|
||||
fastify.get('/search/books/:extension', controller.searchBooksInExtension);
|
||||
fastify.get('/book/:id/chapters', controller.getChapters);
|
||||
fastify.get('/book/:bookId/:chapter/:provider', controller.getChapterContent);
|
||||
}
|
||||
|
||||
export default booksRoutes;
|
||||
401
src/api/books/books.service.ts
Normal file
401
src/api/books/books.service.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import { getCachedExtension, cacheExtension, getCache, setCache, getExtensionTitle } from '../../shared/queries';
|
||||
import { queryOne, queryAll } from '../../shared/database';
|
||||
import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions';
|
||||
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
|
||||
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function getBookById(id: string | number): Promise<Book | { error: string }> {
|
||||
const row = await queryOne("SELECT full_data FROM books WHERE id = ?", [id]);
|
||||
|
||||
if (row) {
|
||||
return JSON.parse(row.full_data);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[Book] Local miss for ID ${id}, fetching live...`);
|
||||
const query = `
|
||||
query ($id: Int) {
|
||||
Media(id: $id, type: MANGA) {
|
||||
id idMal title { romaji english native userPreferred } type format status description
|
||||
startDate { year month day } endDate { year month day } season seasonYear seasonInt
|
||||
episodes duration chapters volumes countryOfOrigin isLicensed source hashtag
|
||||
trailer { id site thumbnail } updatedAt coverImage { extraLarge large medium color }
|
||||
bannerImage genres synonyms averageScore meanScore popularity isLocked trending favourites
|
||||
tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult userId }
|
||||
relations { edges { relationType node { id title { romaji } } } }
|
||||
characters(page: 1, perPage: 10) { nodes { id name { full } } }
|
||||
studios { nodes { id name isAnimationStudio } }
|
||||
isAdult nextAiringEpisode { airingAt timeUntilAiring episode }
|
||||
externalLinks { url site }
|
||||
rankings { id rank type format year season allTime context }
|
||||
}
|
||||
}`;
|
||||
|
||||
const response = await fetch('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ query, variables: { id: parseInt(id.toString()) } })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.data && data.data.Media) {
|
||||
return data.data.Media;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Fetch error:", e);
|
||||
}
|
||||
|
||||
return { error: "Book not found" };
|
||||
}
|
||||
|
||||
export async function getTrendingBooks(): Promise<Book[]> {
|
||||
const rows = await queryAll("SELECT full_data FROM trending_books ORDER BY rank ASC LIMIT 10");
|
||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
||||
}
|
||||
|
||||
export async function getPopularBooks(): Promise<Book[]> {
|
||||
const rows = await queryAll("SELECT full_data FROM popular_books ORDER BY rank ASC LIMIT 10");
|
||||
return rows.map((r: { full_data: string; }) => JSON.parse(r.full_data));
|
||||
}
|
||||
|
||||
export async function searchBooksLocal(query: string): Promise<Book[]> {
|
||||
if (!query || query.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sql = `SELECT full_data FROM books WHERE full_data LIKE ? LIMIT 50`;
|
||||
const rows = await queryAll(sql, [`%${query}%`]);
|
||||
|
||||
const results: Book[] = rows.map((row: { full_data: string; }) => JSON.parse(row.full_data));
|
||||
|
||||
const clean = results.filter(book => {
|
||||
const searchTerms = [
|
||||
book.title.english,
|
||||
book.title.romaji,
|
||||
book.title.native,
|
||||
...(book.synonyms || [])
|
||||
].filter(Boolean).map(t => t!.toLowerCase());
|
||||
|
||||
return searchTerms.some(term => term.includes(query.toLowerCase()));
|
||||
});
|
||||
|
||||
return clean.slice(0, 10);
|
||||
}
|
||||
|
||||
export async function searchBooksAniList(query: string): Promise<Book[]> {
|
||||
const gql = `
|
||||
query ($search: String) {
|
||||
Page(page: 1, perPage: 5) {
|
||||
media(search: $search, type: MANGA, isAdult: false) {
|
||||
id title { romaji english native }
|
||||
coverImage { extraLarge large }
|
||||
bannerImage description averageScore format
|
||||
seasonYear startDate { year }
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const response = await fetch('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ query: gql, variables: { search: query } })
|
||||
});
|
||||
|
||||
const liveData = await response.json();
|
||||
|
||||
if (liveData.data && liveData.data.Page.media.length > 0) {
|
||||
return liveData.data.Page.media;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function getBookInfoExtension(ext: Extension | null, id: string): Promise<Book[]> {
|
||||
if (!ext) return [];
|
||||
|
||||
const extName = ext.constructor.name;
|
||||
|
||||
const cached = await getCachedExtension(extName, id);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached.metadata);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (ext.type === 'book-board' && ext.getMetadata) {
|
||||
try {
|
||||
const info = await ext.getMetadata(id);
|
||||
|
||||
if (info) {
|
||||
await cacheExtension(extName, id, info.title, info);
|
||||
return info;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Extension getInfo failed:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function searchBooksInExtension(ext: Extension | null, name: string, query: string): Promise<Book[]> {
|
||||
if (!ext) return [];
|
||||
|
||||
if ((ext.type === 'book-board') && ext.search) {
|
||||
try {
|
||||
console.log(`[${name}] Searching for book: ${query}`);
|
||||
const matches = await ext.search({
|
||||
query: query,
|
||||
media: {
|
||||
romajiTitle: query,
|
||||
englishTitle: query,
|
||||
startDate: { year: 0, month: 0, day: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
if (matches && matches.length > 0) {
|
||||
return matches.map(m => ({
|
||||
id: m.id,
|
||||
extensionName: name,
|
||||
title: { romaji: m.title, english: m.title, native: null },
|
||||
coverImage: { large: m.image || '' },
|
||||
averageScore: m.rating || m.score || null,
|
||||
format: m.format,
|
||||
seasonYear: null,
|
||||
isExtensionResult: true
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Extension search failed for ${name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function fetchBookMetadata(id: string): Promise<Book | null> {
|
||||
try {
|
||||
const query = `query ($id: Int) {
|
||||
Media(id: $id, type: MANGA) {
|
||||
title { romaji english }
|
||||
startDate { year month day }
|
||||
}
|
||||
}`;
|
||||
|
||||
const res = await fetch('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, variables: { id: parseInt(id) } })
|
||||
});
|
||||
|
||||
const d = await res.json();
|
||||
return d.data?.Media || null;
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch book metadata:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchChaptersInExtension(ext: Extension, name: string, searchTitle: string, search: boolean): Promise<ChapterWithProvider[]> {
|
||||
const cacheKey = `chapters:${name}:${searchTitle}`;
|
||||
const cached = await getCache(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${name}] Chapters cache hit for: ${searchTitle}`);
|
||||
try {
|
||||
return JSON.parse(cached.result) as ChapterWithProvider[];
|
||||
} catch (e) {
|
||||
console.error(`[${name}] Error parsing cached chapters:`, e);
|
||||
}
|
||||
} else {
|
||||
console.log(`[${name}] Chapters cache expired for: ${searchTitle}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[${name}] Searching chapters for: ${searchTitle}`);
|
||||
|
||||
let mediaId: string;
|
||||
if (search) {
|
||||
const matches = await ext.search!({
|
||||
query: searchTitle,
|
||||
media: {
|
||||
romajiTitle: searchTitle,
|
||||
englishTitle: searchTitle,
|
||||
startDate: { year: 0, month: 0, day: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
const best = matches?.[0];
|
||||
|
||||
if (!best) { return [] }
|
||||
|
||||
mediaId = best.id;
|
||||
|
||||
} else {
|
||||
const match = await ext.getMetadata(searchTitle);
|
||||
mediaId = match.id;
|
||||
}
|
||||
|
||||
const chaps = await ext.findChapters!(mediaId);
|
||||
|
||||
if (!chaps?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`[${name}] Found ${chaps.length} chapters.`);
|
||||
const result: ChapterWithProvider[] = chaps.map((ch) => ({
|
||||
id: ch.id,
|
||||
number: parseFloat(ch.number.toString()),
|
||||
title: ch.title,
|
||||
date: ch.releaseDate,
|
||||
provider: name
|
||||
}));
|
||||
|
||||
await setCache(cacheKey, result, CACHE_TTL_MS);
|
||||
return result;
|
||||
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error(`Failed to fetch chapters from ${name}:`, error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChaptersForBook(id: string): Promise<{ chapters: ChapterWithProvider[] }> {
|
||||
let bookData: Book | null = null;
|
||||
let searchTitle: string = "";
|
||||
|
||||
if (!isNaN(Number(id))) {
|
||||
const result = await getBookById(id);
|
||||
if (!result || "error" in result) return { chapters: [] }
|
||||
bookData = result;
|
||||
const titles = [bookData.title.english, bookData.title.romaji].filter(Boolean) as string[];
|
||||
searchTitle = titles[0];
|
||||
}
|
||||
const bookExtensions = getBookExtensionsMap();
|
||||
|
||||
let extension;
|
||||
if (!searchTitle) {
|
||||
for (const [name, ext] of bookExtensions) {
|
||||
const title = await getExtensionTitle(name, id)
|
||||
if (title){
|
||||
searchTitle = title;
|
||||
extension = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allChapters: any[] = [];
|
||||
|
||||
for (const [name, ext] of bookExtensions) {
|
||||
if (name == extension) {
|
||||
const chapters = await searchChaptersInExtension(ext, name, id, false);
|
||||
allChapters.push(...chapters);
|
||||
} else {
|
||||
const chapters = await searchChaptersInExtension(ext, name, searchTitle, true);
|
||||
allChapters.push(...chapters);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chapters: allChapters.sort((a, b) => Number(a.number) - Number(b.number))
|
||||
};
|
||||
}
|
||||
|
||||
export async function getChapterContent(bookId: string, chapterIndex: string, providerName: string): Promise<ChapterContent> {
|
||||
const extensions = getAllExtensions();
|
||||
const ext = extensions.get(providerName);
|
||||
|
||||
if (!ext) {
|
||||
throw new Error("Provider not found");
|
||||
}
|
||||
|
||||
const contentCacheKey = `content:${providerName}:${bookId}:${chapterIndex}`;
|
||||
const cachedContent = await getCache(contentCacheKey);
|
||||
|
||||
if (cachedContent) {
|
||||
const isExpired = Date.now() - cachedContent.created_at > CACHE_TTL_MS;
|
||||
|
||||
if (!isExpired) {
|
||||
console.log(`[${providerName}] Content cache hit for Book ID ${bookId}, Index ${chapterIndex}`);
|
||||
try {
|
||||
return JSON.parse(cachedContent.result) as ChapterContent;
|
||||
} catch (e) {
|
||||
console.error(`[${providerName}] Error parsing cached content:`, e);
|
||||
|
||||
}
|
||||
} else {
|
||||
console.log(`[${providerName}] Content cache expired for Book ID ${bookId}, Index ${chapterIndex}`);
|
||||
}
|
||||
}
|
||||
|
||||
const chapterList = await getChaptersForBook(bookId);
|
||||
|
||||
if (!chapterList?.chapters || chapterList.chapters.length === 0) {
|
||||
throw new Error("Chapters not found");
|
||||
}
|
||||
|
||||
const providerChapters = chapterList.chapters.filter(c => c.provider === providerName);
|
||||
const index = parseInt(chapterIndex, 10);
|
||||
|
||||
if (Number.isNaN(index)) {
|
||||
throw new Error("Invalid chapter index");
|
||||
}
|
||||
|
||||
if (!providerChapters[index]) {
|
||||
throw new Error("Chapter index out of range");
|
||||
}
|
||||
|
||||
const selectedChapter = providerChapters[index];
|
||||
|
||||
const chapterId = selectedChapter.id;
|
||||
const chapterTitle = selectedChapter.title || null;
|
||||
const chapterNumber = typeof selectedChapter.number === 'number' ? selectedChapter.number : index;
|
||||
|
||||
try {
|
||||
if (!ext.findChapterPages) {
|
||||
throw new Error("Extension doesn't support findChapterPages");
|
||||
}
|
||||
|
||||
let contentResult: ChapterContent;
|
||||
|
||||
if (ext.mediaType === "manga") {
|
||||
const pages = await ext.findChapterPages(chapterId);
|
||||
contentResult = {
|
||||
type: "manga",
|
||||
chapterId,
|
||||
title: chapterTitle,
|
||||
number: chapterNumber,
|
||||
provider: providerName,
|
||||
pages
|
||||
};
|
||||
} else if (ext.mediaType === "ln") {
|
||||
const content = await ext.findChapterPages(chapterId);
|
||||
contentResult = {
|
||||
type: "ln",
|
||||
chapterId,
|
||||
title: chapterTitle,
|
||||
number: chapterNumber,
|
||||
provider: providerName,
|
||||
content
|
||||
};
|
||||
} else {
|
||||
throw new Error("Unknown mediaType");
|
||||
}
|
||||
|
||||
await setCache(contentCacheKey, contentResult, CACHE_TTL_MS);
|
||||
|
||||
return contentResult;
|
||||
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error(`[Chapter] Error loading from ${providerName}:`, error.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
37
src/api/extensions/extensions.controller.ts
Normal file
37
src/api/extensions/extensions.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getExtension, getExtensionsList, getGalleryExtensionsMap, getBookExtensionsMap, getAnimeExtensionsMap } from '../../shared/extensions';
|
||||
import { ExtensionNameRequest } from '../types';
|
||||
|
||||
export async function getExtensions(req: FastifyRequest, reply: FastifyReply) {
|
||||
return { extensions: getExtensionsList() };
|
||||
}
|
||||
|
||||
export async function getAnimeExtensions(req: FastifyRequest, reply: FastifyReply) {
|
||||
const animeExtensions = getAnimeExtensionsMap();
|
||||
return { extensions: Array.from(animeExtensions.keys()) };
|
||||
}
|
||||
|
||||
export async function getBookExtensions(req: FastifyRequest, reply: FastifyReply) {
|
||||
const bookExtensions = getBookExtensionsMap();
|
||||
return { extensions: Array.from(bookExtensions.keys()) };
|
||||
}
|
||||
|
||||
export async function getGalleryExtensions(req: FastifyRequest, reply: FastifyReply) {
|
||||
const galleryExtensions = getGalleryExtensionsMap();
|
||||
return { extensions: Array.from(galleryExtensions.keys()) };
|
||||
}
|
||||
|
||||
export async function getExtensionSettings(req: ExtensionNameRequest, reply: FastifyReply) {
|
||||
const { name } = req.params;
|
||||
const ext = getExtension(name);
|
||||
|
||||
if (!ext) {
|
||||
return { error: "Extension not found" };
|
||||
}
|
||||
|
||||
if (!ext.getSettings) {
|
||||
return { episodeServers: ["default"], supportsDub: false };
|
||||
}
|
||||
|
||||
return ext.getSettings();
|
||||
}
|
||||
12
src/api/extensions/extensions.routes.ts
Normal file
12
src/api/extensions/extensions.routes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './extensions.controller';
|
||||
|
||||
async function extensionsRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/extensions', controller.getExtensions);
|
||||
fastify.get('/extensions/anime', controller.getAnimeExtensions);
|
||||
fastify.get('/extensions/book', controller.getBookExtensions);
|
||||
fastify.get('/extensions/gallery', controller.getGalleryExtensions);
|
||||
fastify.get('/extensions/:name/settings', controller.getExtensionSettings);
|
||||
}
|
||||
|
||||
export default extensionsRoutes;
|
||||
145
src/api/gallery/gallery.controller.ts
Normal file
145
src/api/gallery/gallery.controller.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {FastifyReply, FastifyRequest} from 'fastify';
|
||||
import * as galleryService from './gallery.service';
|
||||
import {AddFavoriteBody, RemoveFavoriteParams} from '../types'
|
||||
|
||||
export async function search(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const query = req.query.q || '';
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const perPage = parseInt(req.query.perPage as string) || 48;
|
||||
|
||||
return await galleryService.searchGallery(query, page, perPage);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error("Gallery Search Error:", error.message);
|
||||
return {
|
||||
results: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
hasNextPage: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchInExtension(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const provider = req.query.provider;
|
||||
const query = req.query.q || '';
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const perPage = parseInt(req.query.perPage as string) || 48;
|
||||
|
||||
if (!provider) {
|
||||
return reply.code(400).send({ error: "Missing provider" });
|
||||
}
|
||||
|
||||
return await galleryService.searchInExtension(provider, query, page, perPage);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Gallery SearchInExtension Error:", (err as Error).message);
|
||||
|
||||
return {
|
||||
results: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
hasNextPage: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInfo(req: any, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const provider = req.query.provider;
|
||||
|
||||
return await galleryService.getGalleryInfo(id, provider);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error("Gallery Info Error:", error.message);
|
||||
return reply.code(404).send({ error: "Gallery item not found" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFavorites(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const favorites = await galleryService.getFavorites();
|
||||
return { favorites };
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error("Get Favorites Error:", error.message);
|
||||
return reply.code(500).send({ error: "Failed to retrieve favorites" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFavoriteById(req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const favorite = await galleryService.getFavoriteById(id);
|
||||
|
||||
if (!favorite) {
|
||||
return reply.code(404).send({ error: "Favorite not found" });
|
||||
}
|
||||
|
||||
return { favorite };
|
||||
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error("Get Favorite By ID Error:", error.message);
|
||||
return reply.code(500).send({ error: "Failed to retrieve favorite" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function addFavorite(req: FastifyRequest<{ Body: AddFavoriteBody }>, reply: FastifyReply) {
|
||||
try {
|
||||
const {id, title, image_url, thumbnail_url, tags, provider, headers} = req.body;
|
||||
|
||||
if (!id || !title || !image_url || !thumbnail_url) {
|
||||
return reply.code(400).send({
|
||||
error: "Missing required fields: id, title, image_url, thumbnail_url"
|
||||
});
|
||||
}
|
||||
|
||||
const result = await galleryService.addFavorite({
|
||||
id,
|
||||
title,
|
||||
image_url,
|
||||
thumbnail_url,
|
||||
tags: tags || '',
|
||||
provider: provider || "",
|
||||
headers: headers || ""
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return reply.code(201).send(result);
|
||||
} else {
|
||||
return reply.code(409).send(result);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Add Favorite Error:", (err as Error).message);
|
||||
return reply.code(500).send({ error: "Failed to add favorite" });
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeFavorite(req: FastifyRequest<{ Params: RemoveFavoriteParams }>, reply: FastifyReply) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return reply.code(400).send({ error: "Missing favorite ID" });
|
||||
}
|
||||
|
||||
const result = await galleryService.removeFavorite(id);
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, message: "Favorite removed successfully" };
|
||||
} else {
|
||||
return reply.code(404).send({ error: "Favorite not found" });
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
console.error("Remove Favorite Error:", error.message);
|
||||
return reply.code(500).send({ error: "Failed to remove favorite" });
|
||||
}
|
||||
}
|
||||
14
src/api/gallery/gallery.routes.ts
Normal file
14
src/api/gallery/gallery.routes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import * as controller from './gallery.controller';
|
||||
|
||||
async function galleryRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/gallery/search', controller.search);
|
||||
fastify.get('/gallery/fetch/:id', controller.getInfo);
|
||||
fastify.get('/gallery/search/provider', controller.searchInExtension);
|
||||
fastify.get('/gallery/favorites', controller.getFavorites);
|
||||
fastify.get('/gallery/favorites/:id', controller.getFavoriteById);
|
||||
fastify.post('/gallery/favorites', controller.addFavorite);
|
||||
fastify.delete('/gallery/favorites/:id', controller.removeFavorite);
|
||||
}
|
||||
|
||||
export default galleryRoutes;
|
||||
191
src/api/gallery/gallery.service.ts
Normal file
191
src/api/gallery/gallery.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { getAllExtensions, getExtension } from '../../shared/extensions';
|
||||
import { GallerySearchResult, GalleryInfo, Favorite, FavoriteResult } from '../types';
|
||||
import { getDatabase } from '../../shared/database';
|
||||
|
||||
export async function searchGallery(query: string, page: number = 1, perPage: number = 48): Promise<GallerySearchResult> {
|
||||
const extensions = getAllExtensions();
|
||||
|
||||
for (const [name, ext] of extensions) {
|
||||
if (ext.type === 'image-board' && ext.search) {
|
||||
const result = await searchInExtension(name, query, page, perPage);
|
||||
if (result.results.length > 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: 0,
|
||||
next: 0,
|
||||
previous: 0,
|
||||
pages: 0,
|
||||
page,
|
||||
hasNextPage: false,
|
||||
results: []
|
||||
};
|
||||
}
|
||||
|
||||
export async function getGalleryInfo(id: string, providerName?: string): Promise<GalleryInfo> {
|
||||
const extensions = getAllExtensions();
|
||||
|
||||
if (providerName) {
|
||||
const ext = extensions.get(providerName);
|
||||
if (ext && ext.type === 'image-board' && ext.getInfo) {
|
||||
try {
|
||||
console.log(`[Gallery] Getting info from ${providerName} for: ${id}`);
|
||||
const info = await ext.getInfo(id);
|
||||
return {
|
||||
...info,
|
||||
provider: providerName
|
||||
};
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error(`[Gallery] Failed to get info from ${providerName}:`, error.message);
|
||||
throw new Error(`Failed to get gallery info from ${providerName}`);
|
||||
}
|
||||
}
|
||||
throw new Error("Provider not found or doesn't support getInfo");
|
||||
}
|
||||
|
||||
for (const [name, ext] of extensions) {
|
||||
if (ext.type === 'gallery' && ext.getInfo) {
|
||||
try {
|
||||
console.log(`[Gallery] Trying to get info from ${name} for: ${id}`);
|
||||
const info = await ext.getInfo(id);
|
||||
return {
|
||||
...info,
|
||||
provider: name
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Gallery item not found in any extension");
|
||||
}
|
||||
|
||||
export async function searchInExtension(providerName: string, query: string, page: number = 1, perPage: number = 48): Promise<GallerySearchResult> {
|
||||
const ext = getExtension(providerName);
|
||||
|
||||
if (!ext || ext.type !== 'image-board' || !ext.search) {
|
||||
throw new Error(`La extensión "${providerName}" no existe o no soporta búsqueda.`);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[Gallery] Searching ONLY in ${providerName} for: ${query}`);
|
||||
const results = await ext.search(query, page, perPage);
|
||||
|
||||
const enrichedResults = (results?.results ?? []).map((r: any) => ({
|
||||
...r,
|
||||
provider: providerName
|
||||
}));
|
||||
|
||||
return {
|
||||
...results,
|
||||
results: enrichedResults
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
console.error(`[Gallery] Search failed in ${providerName}:`, error.message);
|
||||
|
||||
return {
|
||||
total: 0,
|
||||
next: 0,
|
||||
previous: 0,
|
||||
pages: 0,
|
||||
page,
|
||||
hasNextPage: false,
|
||||
results: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFavorites(): Promise<Favorite[]> {
|
||||
const db = getDatabase("favorites");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
db.all('SELECT * FROM favorites', [], (err: Error | null, rows: Favorite[]) => {
|
||||
if (err) {
|
||||
console.error('Error getting favorites:', err.message);
|
||||
resolve([]);
|
||||
} else {
|
||||
resolve(rows);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFavoriteById(id: string): Promise<Favorite | null> {
|
||||
const db = getDatabase("favorites");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
db.get(
|
||||
'SELECT * FROM favorites WHERE id = ?',
|
||||
[id],
|
||||
(err: Error | null, row: Favorite | undefined) => {
|
||||
if (err) {
|
||||
console.error('Error getting favorite by id:', err.message);
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(row || null);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function addFavorite(fav: Favorite): Promise<FavoriteResult> {
|
||||
const db = getDatabase("favorites");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const stmt = `
|
||||
INSERT INTO favorites (id, title, image_url, thumbnail_url, tags, headers, provider)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
db.run(
|
||||
stmt,
|
||||
[
|
||||
fav.id,
|
||||
fav.title,
|
||||
fav.image_url,
|
||||
fav.thumbnail_url,
|
||||
fav.tags || "",
|
||||
fav.headers || "",
|
||||
fav.provider || ""
|
||||
],
|
||||
function (err: any) {
|
||||
if (err) {
|
||||
if (err.code && err.code.includes('SQLITE_CONSTRAINT')) {
|
||||
resolve({ success: false, error: 'Item is already a favorite.' });
|
||||
} else {
|
||||
console.error('Error adding favorite:', err.message);
|
||||
resolve({ success: false, error: err.message });
|
||||
}
|
||||
} else {
|
||||
resolve({ success: true, id: fav.id });
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeFavorite(id: string): Promise<FavoriteResult> {
|
||||
const db = getDatabase("favorites");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const stmt = 'DELETE FROM favorites WHERE id = ?';
|
||||
|
||||
db.run(stmt, [id], function (err: Error | null) {
|
||||
if (err) {
|
||||
console.error('Error removing favorite:', err.message);
|
||||
resolve({ success: false, error: err.message });
|
||||
} else {
|
||||
// @ts-ignore - this.changes existe en el contexto de db.run
|
||||
resolve({ success: this.changes > 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
45
src/api/proxy/proxy.controller.ts
Normal file
45
src/api/proxy/proxy.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { proxyRequest, processM3U8Content, streamToReadable } from './proxy.service';
|
||||
import { ProxyRequest } from '../types';
|
||||
|
||||
export async function handleProxy(req: ProxyRequest, reply: FastifyReply) {
|
||||
const { url, referer, origin, userAgent } = req.query;
|
||||
|
||||
if (!url) {
|
||||
return reply.code(400).send({ error: "No URL provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, contentType, isM3U8 } = await proxyRequest(url, {
|
||||
referer,
|
||||
origin,
|
||||
userAgent
|
||||
});
|
||||
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
|
||||
if (contentType) {
|
||||
reply.header('Content-Type', contentType);
|
||||
}
|
||||
|
||||
if (isM3U8) {
|
||||
const text = await response.text();
|
||||
const baseUrl = new URL(response.url);
|
||||
|
||||
const processed = processM3U8Content(text, baseUrl, {
|
||||
referer,
|
||||
origin,
|
||||
userAgent
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
return reply.send(streamToReadable(response.body!));
|
||||
|
||||
} catch (err) {
|
||||
req.server.log.error(err);
|
||||
return reply.code(500).send({ error: "Internal Server Error" });
|
||||
}
|
||||
}
|
||||
8
src/api/proxy/proxy.routes.ts
Normal file
8
src/api/proxy/proxy.routes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { handleProxy } from './proxy.controller';
|
||||
|
||||
async function proxyRoutes(fastify: FastifyInstance) {
|
||||
fastify.get('/proxy', handleProxy);
|
||||
}
|
||||
|
||||
export default proxyRoutes;
|
||||
68
src/api/proxy/proxy.service.ts
Normal file
68
src/api/proxy/proxy.service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Readable } from 'stream';
|
||||
|
||||
interface ProxyHeaders {
|
||||
referer?: string;
|
||||
origin?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
interface ProxyResponse {
|
||||
response: Response;
|
||||
contentType: string | null;
|
||||
isM3U8: boolean;
|
||||
}
|
||||
|
||||
export async function proxyRequest(url: string, { referer, origin, userAgent }: ProxyHeaders): Promise<ProxyResponse> {
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'en-US,en;q=0.9'
|
||||
};
|
||||
|
||||
if (referer) headers['Referer'] = referer;
|
||||
if (origin) headers['Origin'] = origin;
|
||||
|
||||
const response = await fetch(url, { headers, redirect: 'follow' });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Proxy Error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
const isM3U8 = (contentType && contentType.includes('mpegurl')) || url.includes('.m3u8');
|
||||
|
||||
return {
|
||||
response,
|
||||
contentType,
|
||||
isM3U8
|
||||
};
|
||||
}
|
||||
|
||||
export function processM3U8Content(
|
||||
text: string,
|
||||
baseUrl: URL,
|
||||
{ referer, origin, userAgent }: ProxyHeaders
|
||||
): string {
|
||||
return text.replace(/^(?!#)(?!\s*$).+/gm, (line) => {
|
||||
line = line.trim();
|
||||
let absoluteUrl: string;
|
||||
|
||||
try {
|
||||
absoluteUrl = new URL(line, baseUrl).href;
|
||||
} catch (e) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const proxyParams = new URLSearchParams();
|
||||
proxyParams.set('url', absoluteUrl);
|
||||
if (referer) proxyParams.set('referer', referer);
|
||||
if (origin) proxyParams.set('origin', origin);
|
||||
if (userAgent) proxyParams.set('userAgent', userAgent);
|
||||
|
||||
return `/api/proxy?${proxyParams.toString()}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function streamToReadable(webStream: ReadableStream): Readable {
|
||||
return Readable.fromWeb(webStream as any);
|
||||
}
|
||||
291
src/api/types.ts
Normal file
291
src/api/types.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export interface AnimeTitle {
|
||||
romaji: string;
|
||||
english: string | null;
|
||||
native: string | null;
|
||||
userPreferred?: string;
|
||||
}
|
||||
|
||||
export interface CoverImage {
|
||||
extraLarge?: string;
|
||||
large: string;
|
||||
medium?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface StartDate {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
export interface Anime {
|
||||
id: number | string;
|
||||
title: AnimeTitle;
|
||||
coverImage: CoverImage;
|
||||
bannerImage?: string;
|
||||
description?: string;
|
||||
averageScore: number | null;
|
||||
format: string;
|
||||
seasonYear: number | null;
|
||||
startDate?: StartDate;
|
||||
synonyms?: string[];
|
||||
extensionName?: string;
|
||||
isExtensionResult?: boolean;
|
||||
}
|
||||
|
||||
export interface Book {
|
||||
id: number | string;
|
||||
title: AnimeTitle;
|
||||
coverImage: CoverImage;
|
||||
bannerImage?: string;
|
||||
description?: string;
|
||||
averageScore: number | null;
|
||||
format: string;
|
||||
seasonYear: number | null;
|
||||
startDate?: StartDate;
|
||||
synonyms?: string[];
|
||||
extensionName?: string;
|
||||
isExtensionResult?: boolean;
|
||||
}
|
||||
|
||||
export interface ExtensionSearchOptions {
|
||||
query: string;
|
||||
dub?: boolean;
|
||||
media?: {
|
||||
romajiTitle: string;
|
||||
englishTitle: string;
|
||||
startDate: StartDate;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExtensionSearchResult {
|
||||
format: string;
|
||||
headers: any;
|
||||
id: string;
|
||||
title: string;
|
||||
image?: string;
|
||||
rating?: number;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
url: string;
|
||||
id: string;
|
||||
number: number;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
number: string | number;
|
||||
title?: string;
|
||||
releaseDate?: string;
|
||||
}
|
||||
|
||||
export interface ChapterWithProvider extends Chapter {
|
||||
provider: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
getMetadata: any;
|
||||
type: 'anime-board' | 'book-board' | 'manga-board';
|
||||
mediaType?: 'manga' | 'ln';
|
||||
search?: (options: ExtensionSearchOptions) => Promise<ExtensionSearchResult[]>;
|
||||
findEpisodes?: (id: string) => Promise<Episode[]>;
|
||||
findEpisodeServer?: (episode: Episode, server: string) => Promise<any>;
|
||||
findChapters?: (id: string) => Promise<Chapter[]>;
|
||||
findChapterPages?: (chapterId: string) => Promise<any>;
|
||||
getSettings?: () => ExtensionSettings;
|
||||
}
|
||||
|
||||
export interface ExtensionSettings {
|
||||
episodeServers: string[];
|
||||
supportsDub: boolean;
|
||||
}
|
||||
|
||||
export interface StreamData {
|
||||
url?: string;
|
||||
sources?: any[];
|
||||
subtitles?: any[];
|
||||
}
|
||||
|
||||
export interface MangaChapterContent {
|
||||
type: 'manga';
|
||||
chapterId: string;
|
||||
title: string | null;
|
||||
number: number;
|
||||
provider: string;
|
||||
pages: any[];
|
||||
}
|
||||
|
||||
export interface LightNovelChapterContent {
|
||||
type: 'ln';
|
||||
chapterId: string;
|
||||
title: string | null;
|
||||
number: number;
|
||||
provider: string;
|
||||
content: any;
|
||||
}
|
||||
|
||||
export type ChapterContent = MangaChapterContent | LightNovelChapterContent;
|
||||
|
||||
export interface AnimeParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AnimeQuery {
|
||||
ext?: string;
|
||||
}
|
||||
|
||||
export interface SearchQuery {
|
||||
q: string;
|
||||
}
|
||||
|
||||
export interface ExtensionNameParams {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface WatchStreamQuery {
|
||||
animeId: string;
|
||||
episode: string;
|
||||
server?: string;
|
||||
category?: string;
|
||||
ext: string;
|
||||
}
|
||||
|
||||
export interface BookParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface BookQuery {
|
||||
ext?: string;
|
||||
}
|
||||
|
||||
export interface ChapterParams {
|
||||
bookId: string;
|
||||
chapter: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export interface ProxyQuery {
|
||||
url: string;
|
||||
referer?: string;
|
||||
origin?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export type AnimeRequest = FastifyRequest<{
|
||||
Params: AnimeParams;
|
||||
Querystring: AnimeQuery;
|
||||
}>;
|
||||
|
||||
export type SearchRequest = FastifyRequest<{
|
||||
Querystring: SearchQuery;
|
||||
}>;
|
||||
|
||||
export type ExtensionNameRequest = FastifyRequest<{
|
||||
Params: ExtensionNameParams;
|
||||
}>;
|
||||
|
||||
export type WatchStreamRequest = FastifyRequest<{
|
||||
Querystring: WatchStreamQuery;
|
||||
}>;
|
||||
|
||||
export type BookRequest = FastifyRequest<{
|
||||
Params: BookParams;
|
||||
Querystring: BookQuery;
|
||||
}>;
|
||||
|
||||
export type ChapterRequest = FastifyRequest<{
|
||||
Params: ChapterParams;
|
||||
}>;
|
||||
|
||||
export type ProxyRequest = FastifyRequest<{
|
||||
Querystring: ProxyQuery;
|
||||
}>;
|
||||
|
||||
export interface GalleryItemPreview {
|
||||
id: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
type: 'preview';
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface GallerySearchResult {
|
||||
total: number;
|
||||
next: number;
|
||||
previous: number;
|
||||
pages: number;
|
||||
page: number;
|
||||
hasNextPage: boolean;
|
||||
results: GalleryItemPreview[];
|
||||
}
|
||||
|
||||
export interface GalleryInfo {
|
||||
id: string;
|
||||
fullImage: string;
|
||||
resizedImageUrl: string;
|
||||
tags: string[];
|
||||
createdAt: string | null;
|
||||
publishedBy: string;
|
||||
rating: string;
|
||||
comments: any[];
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface GalleryExtension {
|
||||
type: 'gallery';
|
||||
search: (query: string, page: number, perPage: number) => Promise<GallerySearchResult>;
|
||||
getInfo: (id: string) => Promise<Omit<GalleryInfo, 'provider'>>;
|
||||
}
|
||||
|
||||
export interface GallerySearchRequest extends FastifyRequest {
|
||||
query: {
|
||||
q?: string;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GalleryInfoRequest extends FastifyRequest {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
query: {
|
||||
provider?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AddFavoriteBody {
|
||||
id: string;
|
||||
title: string;
|
||||
image_url: string;
|
||||
thumbnail_url: string;
|
||||
tags?: string;
|
||||
provider: string;
|
||||
headers: string;
|
||||
}
|
||||
|
||||
export interface RemoveFavoriteParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface Favorite {
|
||||
id: string;
|
||||
title: string;
|
||||
image_url: string;
|
||||
thumbnail_url: string;
|
||||
tags: string;
|
||||
provider: string;
|
||||
headers: string;
|
||||
}
|
||||
|
||||
export interface FavoriteResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
id?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user