organisation & minor fixes

This commit is contained in:
2025-12-03 17:24:24 +01:00
parent 2cad7ff6ea
commit 8e20743e8b
30 changed files with 158 additions and 497 deletions

View 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 };
}
}

View 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;

View 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;
}

View 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" });
}
}

View 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;

View 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;
}
}

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

View 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;

View 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" });
}
}

View 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;

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

View 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" });
}
}

View 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;

View 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
View 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;
}