Fixed bundling into an exe not working as intended.

This commit is contained in:
2025-12-14 13:16:35 -05:00
parent 76c9eb38f6
commit 5d8441bf27
124 changed files with 279314 additions and 103 deletions

76
electron/api/anilist.js Normal file
View File

@@ -0,0 +1,76 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const database_1 = require("../shared/database");
async function anilist(fastify) {
fastify.get("/anilist", async (request, reply) => {
try {
const { code, state } = request.query;
if (!code)
return reply.status(400).send("No code");
if (!state)
return reply.status(400).send("No user state");
const userId = Number(state);
if (!userId || isNaN(userId)) {
return reply.status(400).send("Invalid user id");
}
const tokenRes = await fetch("https://anilist.co/api/v2/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: process.env.ANILIST_CLIENT_ID,
client_secret: process.env.ANILIST_CLIENT_SECRET,
redirect_uri: "http://localhost:54322/api/anilist",
code
})
});
const tokenData = await tokenRes.json();
if (!tokenData.access_token) {
console.error("AniList token error:", tokenData);
return reply.status(500).send("Failed to get AniList token");
}
const userRes = await fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${tokenData.token_type} ${tokenData.access_token}`
},
body: JSON.stringify({
query: `query { Viewer { id } }`
})
});
const userData = await userRes.json();
const anilistUserId = userData?.data?.Viewer?.id;
if (!anilistUserId) {
console.error("AniList Viewer error:", userData);
return reply.status(500).send("Failed to fetch AniList user");
}
const expiresAt = new Date(Date.now() + tokenData.expires_in * 1000).toISOString();
await (0, database_1.run)(`
INSERT INTO UserIntegration
(user_id, platform, access_token, refresh_token, token_type, anilist_user_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
access_token = excluded.access_token,
refresh_token = excluded.refresh_token,
token_type = excluded.token_type,
anilist_user_id = excluded.anilist_user_id,
expires_at = excluded.expires_at
`, [
userId,
"AniList",
tokenData.access_token,
tokenData.refresh_token,
tokenData.token_type,
anilistUserId,
expiresAt
], "userdata");
return reply.redirect("http://localhost:54322/?anilist=success");
}
catch (e) {
console.error("AniList error:", e);
return reply.redirect("http://localhost:54322/?anilist=error");
}
});
}
exports.default = anilist;

View File

@@ -0,0 +1,76 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const database_1 = require("../../shared/database");
async function anilist(fastify) {
fastify.get("/anilist", async (request, reply) => {
try {
const { code, state } = request.query;
if (!code)
return reply.status(400).send("No code");
if (!state)
return reply.status(400).send("No user state");
const userId = Number(state);
if (!userId || isNaN(userId)) {
return reply.status(400).send("Invalid user id");
}
const tokenRes = await fetch("https://anilist.co/api/v2/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: process.env.ANILIST_CLIENT_ID,
client_secret: process.env.ANILIST_CLIENT_SECRET,
redirect_uri: "http://localhost:54322/api/anilist",
code
})
});
const tokenData = await tokenRes.json();
if (!tokenData.access_token) {
console.error("AniList token error:", tokenData);
return reply.status(500).send("Failed to get AniList token");
}
const userRes = await fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `${tokenData.token_type} ${tokenData.access_token}`
},
body: JSON.stringify({
query: `query { Viewer { id } }`
})
});
const userData = await userRes.json();
const anilistUserId = userData?.data?.Viewer?.id;
if (!anilistUserId) {
console.error("AniList Viewer error:", userData);
return reply.status(500).send("Failed to fetch AniList user");
}
const expiresAt = new Date(Date.now() + tokenData.expires_in * 1000).toISOString();
await (0, database_1.run)(`
INSERT INTO UserIntegration
(user_id, platform, access_token, refresh_token, token_type, anilist_user_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
access_token = excluded.access_token,
refresh_token = excluded.refresh_token,
token_type = excluded.token_type,
anilist_user_id = excluded.anilist_user_id,
expires_at = excluded.expires_at
`, [
userId,
"AniList",
tokenData.access_token,
tokenData.refresh_token,
tokenData.token_type,
anilistUserId,
expiresAt
], "userdata");
return reply.redirect("http://localhost:54322/?anilist=success");
}
catch (e) {
console.error("AniList error:", e);
return reply.redirect("http://localhost:54322/?anilist=error");
}
});
}
exports.default = anilist;

View File

@@ -0,0 +1,478 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getUserAniList = getUserAniList;
exports.updateAniListEntry = updateAniListEntry;
exports.deleteAniListEntry = deleteAniListEntry;
exports.getSingleAniListEntry = getSingleAniListEntry;
const database_1 = require("../../shared/database");
const USER_DB = 'userdata';
// Configuración de reintentos
const RETRY_CONFIG = {
maxRetries: 3,
initialDelay: 1000,
maxDelay: 5000,
backoffMultiplier: 2
};
// Helper para hacer requests con reintentos y manejo de errores
async function fetchWithRetry(url, options, retries = RETRY_CONFIG.maxRetries) {
let lastError = null;
let delay = RETRY_CONFIG.initialDelay;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
// Si es rate limit (429), esperamos más tiempo
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : delay;
if (attempt < retries) {
console.warn(`Rate limited. Esperando ${waitTime}ms antes de reintentar...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
continue;
}
}
// Si es un error de servidor (5xx), reintentamos
if (response.status >= 500 && attempt < retries) {
console.warn(`Error del servidor (${response.status}). Reintentando en ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
continue;
}
return response;
}
catch (error) {
lastError = error;
if (attempt < retries && (error instanceof Error && (error.name === 'AbortError' ||
error.message.includes('fetch') ||
error.message.includes('network')))) {
console.warn(`Error de conexión (intento ${attempt + 1}/${retries + 1}). Reintentando en ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
continue;
}
throw error;
}
}
throw lastError || new Error('Request failed after all retries');
}
async function getUserAniList(appUserId) {
try {
const sql = `
SELECT access_token, anilist_user_id
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
const integration = await (0, database_1.queryOne)(sql, [appUserId], USER_DB);
if (!integration)
return [];
const { access_token, anilist_user_id } = integration;
if (!access_token || !anilist_user_id)
return [];
const query = `
query ($userId: Int) {
anime: MediaListCollection(userId: $userId, type: ANIME) {
lists {
entries {
media {
id
title { romaji english userPreferred }
coverImage { extraLarge }
episodes
nextAiringEpisode { episode }
}
status
progress
score
repeat
notes
private
startedAt { year month day }
completedAt { year month day }
}
}
}
manga: MediaListCollection(userId: $userId, type: MANGA) {
lists {
entries {
media {
id
type
format
title { romaji english userPreferred }
coverImage { extraLarge }
chapters
volumes
}
status
progress
score
repeat
notes
private
startedAt { year month day }
completedAt { year month day }
}
}
}
}
`;
const res = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query,
variables: { userId: anilist_user_id }
}),
});
if (!res.ok)
throw new Error(`AniList API error: ${res.status}`);
const json = await res.json();
if (json?.errors?.length)
throw new Error(json.errors[0].message);
const fromFuzzy = (d) => {
if (!d?.year)
return null;
const m = String(d.month || 1).padStart(2, '0');
const day = String(d.day || 1).padStart(2, '0');
return `${d.year}-${m}-${day}`;
};
const normalize = (lists, type) => {
const result = [];
for (const list of lists || []) {
for (const entry of list.entries || []) {
const media = entry.media;
const totalEpisodes = media?.episodes ||
(media?.nextAiringEpisode?.episode
? media.nextAiringEpisode.episode - 1
: 0);
const totalChapters = media?.chapters ||
(media?.volumes ? media.volumes * 10 : 0);
const resolvedType = type === 'MANGA' &&
(media?.format === 'LIGHT_NOVEL' || media?.format === 'NOVEL')
? 'NOVEL'
: type;
result.push({
user_id: appUserId,
entry_id: media.id,
source: 'anilist',
// ✅ AHORA TU FRONT RECIBE NOVEL
entry_type: resolvedType,
status: entry.status,
progress: entry.progress || 0,
score: entry.score || null,
start_date: fromFuzzy(entry.startedAt),
end_date: fromFuzzy(entry.completedAt),
repeat_count: entry.repeat || 0,
notes: entry.notes || null,
is_private: entry.private ? 1 : 0,
title: media?.title?.userPreferred
|| media?.title?.english
|| media?.title?.romaji
|| 'Unknown Title',
poster: media?.coverImage?.extraLarge
|| 'https://placehold.co/400x600?text=No+Cover',
total_episodes: resolvedType === 'ANIME' ? totalEpisodes : undefined,
total_chapters: resolvedType !== 'ANIME' ? totalChapters : undefined,
updated_at: new Date().toISOString()
});
}
}
return result;
};
return [
...normalize(json?.data?.anime?.lists, 'ANIME'),
...normalize(json?.data?.manga?.lists, 'MANGA')
];
}
catch (error) {
console.error('Error fetching AniList data:', error);
return [];
}
}
async function updateAniListEntry(token, params) {
try {
if (!token)
throw new Error('AniList token is required');
const mutation = `
mutation (
$mediaId: Int,
$status: MediaListStatus,
$progress: Int,
$score: Float,
$startedAt: FuzzyDateInput,
$completedAt: FuzzyDateInput,
$repeat: Int,
$notes: String,
$private: Boolean
) {
SaveMediaListEntry (
mediaId: $mediaId,
status: $status,
progress: $progress,
score: $score,
startedAt: $startedAt,
completedAt: $completedAt,
repeat: $repeat,
notes: $notes,
private: $private
) {
id
status
progress
score
startedAt { year month day }
completedAt { year month day }
repeat
notes
private
}
}
`;
const toFuzzyDate = (dateStr) => {
if (!dateStr)
return null;
const [year, month, day] = dateStr.split('-').map(Number);
return { year, month, day };
};
const variables = {
mediaId: Number(params.mediaId),
status: params.status ?? undefined,
progress: params.progress ?? undefined,
score: params.score ?? undefined,
startedAt: toFuzzyDate(params.start_date),
completedAt: toFuzzyDate(params.end_date),
repeat: params.repeat_count ?? undefined,
notes: params.notes ?? undefined,
private: typeof params.is_private === 'boolean'
? params.is_private
: params.is_private === 1
};
const res = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ query: mutation, variables }),
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`AniList update failed: ${res.status} - ${errorText}`);
}
const json = await res.json();
if (json?.errors?.length) {
throw new Error(`AniList GraphQL error: ${json.errors[0].message}`);
}
return json.data?.SaveMediaListEntry || null;
}
catch (error) {
console.error('Error updating AniList entry:', error);
throw error;
}
}
async function deleteAniListEntry(token, mediaId) {
if (!token)
throw new Error("AniList token required");
try {
// 1⃣ OBTENER VIEWER
const viewerQuery = `query { Viewer { id name } }`;
const vRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ query: viewerQuery }),
});
const vJson = await vRes.json();
const userId = vJson?.data?.Viewer?.id;
if (!userId)
throw new Error("Invalid AniList token");
// 2⃣ DETECTAR TIPO REAL DEL MEDIA
const mediaQuery = `
query ($id: Int) {
Media(id: $id) {
id
type
}
}
`;
const mTypeRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query: mediaQuery,
variables: { id: mediaId }
}),
});
const mTypeJson = await mTypeRes.json();
const mediaType = mTypeJson?.data?.Media?.type;
if (!mediaType) {
throw new Error("Media not found in AniList");
}
// 3⃣ BUSCAR ENTRY CON TIPO REAL
const listQuery = `
query ($userId: Int, $mediaId: Int, $type: MediaType) {
MediaList(userId: $userId, mediaId: $mediaId, type: $type) {
id
}
}
`;
const qRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query: listQuery,
variables: {
userId,
mediaId,
type: mediaType
}
}),
});
const qJson = await qRes.json();
const listEntryId = qJson?.data?.MediaList?.id;
if (!listEntryId) {
throw new Error("Entry not found in user's AniList");
}
// 4⃣ BORRAR
const mutation = `
mutation ($id: Int) {
DeleteMediaListEntry(id: $id) {
deleted
}
}
`;
const delRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query: mutation,
variables: { id: listEntryId }
}),
});
const delJson = await delRes.json();
if (delJson?.errors?.length) {
throw new Error(delJson.errors[0].message);
}
return true;
}
catch (err) {
console.error("AniList DELETE failed:", err);
throw err;
}
}
async function getSingleAniListEntry(token, mediaId, type) {
try {
if (!token) {
throw new Error('AniList token is required');
}
// 1⃣ Obtener userId desde el token
const viewerRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query: `query { Viewer { id } }`
})
});
const viewerJson = await viewerRes.json();
const userId = viewerJson?.data?.Viewer?.id;
if (!userId) {
throw new Error('Failed to get AniList userId');
}
// 2⃣ Query correcta con userId
const query = `
query ($mediaId: Int, $type: MediaType, $userId: Int) {
MediaList(mediaId: $mediaId, type: $type, userId: $userId) {
id
status
progress
score
repeat
private
notes
startedAt { year month day }
completedAt { year month day }
}
}
`;
const res = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query,
variables: { mediaId, type, userId }
})
});
if (res.status === 404) {
return null; // ✅ No existe entry todavía → es totalmente válido
}
if (!res.ok) {
const errorText = await res.text();
throw new Error(`AniList fetch failed: ${res.status} - ${errorText}`);
}
const json = await res.json();
if (json?.errors?.length) {
if (json.errors[0].status === 404)
return null;
throw new Error(`GraphQL error: ${json.errors[0].message}`);
}
const entry = json?.data?.MediaList;
if (!entry)
return null;
return {
entry_id: mediaId,
source: 'anilist',
entry_type: type,
status: entry.status,
progress: entry.progress || 0,
score: entry.score ?? null,
start_date: entry.startedAt?.year
? `${entry.startedAt.year}-${String(entry.startedAt.month).padStart(2, '0')}-${String(entry.startedAt.day).padStart(2, '0')}`
: null,
end_date: entry.completedAt?.year
? `${entry.completedAt.year}-${String(entry.completedAt.month).padStart(2, '0')}-${String(entry.completedAt.day).padStart(2, '0')}`
: null,
repeat_count: entry.repeat || 0,
notes: entry.notes || null,
is_private: entry.private ? 1 : 0,
};
}
catch (error) {
console.error('Error fetching single AniList entry:', error);
throw error;
}
}

View File

@@ -0,0 +1,130 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.getAnime = getAnime;
exports.getAnimeEpisodes = getAnimeEpisodes;
exports.getTrending = getTrending;
exports.getTopAiring = getTopAiring;
exports.search = search;
exports.searchInExtension = searchInExtension;
exports.getWatchStream = getWatchStream;
const animeService = __importStar(require("./anime.service"));
const extensions_1 = require("../../shared/extensions");
async function getAnime(req, reply) {
try {
const { id } = req.params;
const source = req.query.source;
let anime;
if (source === 'anilist') {
anime = await animeService.getAnimeById(id);
}
else {
const ext = (0, extensions_1.getExtension)(source);
anime = await animeService.getAnimeInfoExtension(ext, id);
}
return anime;
}
catch (err) {
return { error: "Database error" };
}
}
async function getAnimeEpisodes(req, reply) {
try {
const { id } = req.params;
const source = req.query.source || 'anilist';
const ext = (0, extensions_1.getExtension)(source);
return await animeService.searchEpisodesInExtension(ext, source, id);
}
catch (err) {
return { error: "Database error" };
}
}
async function getTrending(req, reply) {
try {
const results = await animeService.getTrendingAnime();
return { results };
}
catch (err) {
return { results: [] };
}
}
async function getTopAiring(req, reply) {
try {
const results = await animeService.getTopAiringAnime();
return { results };
}
catch (err) {
return { results: [] };
}
}
async function search(req, reply) {
try {
const query = req.query.q;
const results = await animeService.searchAnimeLocal(query);
if (results.length > 0) {
return { results: results };
}
}
catch (err) {
return { results: [] };
}
}
async function searchInExtension(req, reply) {
try {
const extensionName = req.params.extension;
const query = req.query.q;
const ext = (0, extensions_1.getExtension)(extensionName);
if (!ext)
return { results: [] };
const results = await animeService.searchAnimeInExtension(ext, extensionName, query);
return { results };
}
catch {
return { results: [] };
}
}
async function getWatchStream(req, reply) {
try {
const { animeId, episode, server, category, ext, source } = req.query;
const extension = (0, extensions_1.getExtension)(ext);
if (!extension)
return { error: "Extension not found" };
return await animeService.getStreamData(extension, episode, animeId, source, server, category);
}
catch (err) {
const error = err;
return { error: error.message };
}
}

View File

@@ -0,0 +1,46 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const controller = __importStar(require("./anime.controller"));
async function animeRoutes(fastify) {
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);
}
exports.default = animeRoutes;

View File

@@ -0,0 +1,375 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getAnimeById = getAnimeById;
exports.getTrendingAnime = getTrendingAnime;
exports.getTopAiringAnime = getTopAiringAnime;
exports.searchAnimeLocal = searchAnimeLocal;
exports.getAnimeInfoExtension = getAnimeInfoExtension;
exports.searchAnimeInExtension = searchAnimeInExtension;
exports.searchEpisodesInExtension = searchEpisodesInExtension;
exports.getStreamData = getStreamData;
const queries_1 = require("../../shared/queries");
const database_1 = require("../../shared/database");
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const TTL = 60 * 60 * 6;
const ANILIST_URL = "https://graphql.anilist.co";
const MEDIA_FIELDS = `
id
idMal
title { romaji english native userPreferred }
type
format
status
description
startDate { year month day }
endDate { year month day }
season
seasonYear
episodes
duration
chapters
volumes
countryOfOrigin
isLicensed
source
hashtag
trailer { id site thumbnail }
updatedAt
coverImage { extraLarge large medium color }
bannerImage
genres
synonyms
averageScore
popularity
isLocked
trending
favourites
isAdult
siteUrl
tags { id name description category rank isGeneralSpoiler isMediaSpoiler isAdult }
relations {
edges {
relationType
node {
id
title { romaji }
type
format
status
}
}
}
studios {
edges {
isMain
node { id name isAnimationStudio }
}
}
nextAiringEpisode { airingAt timeUntilAiring episode }
externalLinks { id url site type language color icon notes }
rankings { id rank type format year season allTime context }
stats {
scoreDistribution { score amount }
statusDistribution { status amount }
}
recommendations(perPage: 7, sort: RATING_DESC) {
nodes {
mediaRecommendation {
id
title { romaji }
coverImage { medium }
format
type
}
}
}
`;
async function fetchAniList(query, variables) {
const res = await fetch(ANILIST_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables })
});
const json = await res.json();
return json?.data;
}
async function getAnimeById(id) {
const row = await (0, database_1.queryOne)("SELECT full_data FROM anime WHERE id = ?", [id]);
if (row)
return JSON.parse(row.full_data);
const query = `
query ($id: Int) {
Media(id: $id, type: ANIME) { ${MEDIA_FIELDS} }
}
`;
const data = await fetchAniList(query, { id: Number(id) });
if (!data?.Media)
return { error: "Anime not found" };
await (0, database_1.queryOne)("INSERT INTO anime (id, title, updatedAt, full_data) VALUES (?, ?, ?, ?)", [
data.Media.id,
data.Media.title?.english || data.Media.title?.romaji,
data.Media.updatedAt || 0,
JSON.stringify(data.Media)
]);
return data.Media;
}
async function getTrendingAnime() {
const rows = await (0, database_1.queryAll)("SELECT full_data, updated_at FROM trending ORDER BY rank ASC LIMIT 10");
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r) => JSON.parse(r.full_data));
}
}
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: ANIME, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await (0, database_1.queryOne)("DELETE FROM trending");
let rank = 1;
for (const anime of list) {
await (0, database_1.queryOne)("INSERT INTO trending (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", [rank++, anime.id, JSON.stringify(anime), now]);
}
return list;
}
async function getTopAiringAnime() {
const rows = await (0, database_1.queryAll)("SELECT full_data, updated_at FROM top_airing ORDER BY rank ASC LIMIT 10");
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r) => JSON.parse(r.full_data));
}
}
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: ANIME, status: RELEASING, sort: SCORE_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await (0, database_1.queryOne)("DELETE FROM top_airing");
let rank = 1;
for (const anime of list) {
await (0, database_1.queryOne)("INSERT INTO top_airing (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", [rank++, anime.id, JSON.stringify(anime), now]);
}
return list;
}
async function searchAnimeLocal(query) {
if (!query || query.length < 2)
return [];
const sql = `SELECT full_data FROM anime WHERE full_data LIKE ? LIMIT 50`;
const rows = await (0, database_1.queryAll)(sql, [`%${query}%`]);
const localResults = rows
.map((r) => JSON.parse(r.full_data))
.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));
})
.slice(0, 10);
if (localResults.length >= 5) {
return localResults;
}
const gql = `
query ($search: String) {
Page(page: 1, perPage: 10) {
media(type: ANIME, search: $search) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(gql, { search: query });
const remoteResults = data?.Page?.media || [];
for (const anime of remoteResults) {
await (0, database_1.queryOne)("INSERT OR IGNORE INTO anime (id, title, updatedAt, full_data) VALUES (?, ?, ?, ?)", [
anime.id,
anime.title?.english || anime.title?.romaji,
anime.updatedAt || 0,
JSON.stringify(anime)
]);
}
const merged = [...localResults];
for (const anime of remoteResults) {
if (!merged.find(a => a.id === anime.id)) {
merged.push(anime);
}
if (merged.length >= 10)
break;
}
return merged;
}
async function getAnimeInfoExtension(ext, id) {
if (!ext)
return { error: "not found" };
const extName = ext.constructor.name;
const cached = await (0, queries_1.getCachedExtension)(extName, id);
if (cached) {
try {
console.log(`[${extName}] Metadata cache hit for ID: ${id}`);
return JSON.parse(cached.metadata);
}
catch {
}
}
if ((ext.type === 'anime-board') && ext.getMetadata) {
try {
const match = await ext.getMetadata(id);
if (match) {
await (0, queries_1.cacheExtension)(extName, id, match.title, match);
return match;
}
}
catch (e) {
console.error(`Extension getMetadata failed:`, e);
}
}
return { error: "not found" };
}
async function searchAnimeInExtension(ext, name, query) {
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 [];
}
async function searchEpisodesInExtension(ext, name, query) {
if (!ext)
return [];
const cacheKey = `anime:episodes:${name}:${query}`;
const cached = await (0, queries_1.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);
}
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 (0, queries_1.getExtensionTitle)(name, query);
let mediaId;
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 = chapterList.map(ep => ({
id: ep.id,
number: ep.number,
url: ep.url,
title: ep.title
}));
await (0, queries_1.setCache)(cacheKey, result, CACHE_TTL_MS);
return result;
}
catch (e) {
console.error(`Extension search failed for ${name}:`, e);
}
}
return [];
}
async function getStreamData(extension, episode, id, source, server, category) {
const providerName = extension.constructor.name;
const cacheKey = `anime:stream:${providerName}:${id}:${episode}:${server || 'default'}:${category || 'sub'}`;
const cached = await (0, queries_1.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);
}
catch (e) {
console.error(`[${providerName}] Error parsing cached stream data:`, e);
}
}
else {
console.log(`[${providerName}] Stream data cache expired for episode ${episode}`);
}
}
if (!extension.findEpisodes || !extension.findEpisodeServer) {
throw new Error("Extension doesn't support required methods");
}
let episodes;
if (source === "anilist") {
const anime = await getAnimeById(id);
episodes = await searchEpisodesInExtension(extension, extension.constructor.name, anime.title.romaji);
}
else {
episodes = await extension.findEpisodes(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 (0, queries_1.setCache)(cacheKey, streamData, CACHE_TTL_MS);
return streamData;
}

View File

@@ -0,0 +1,140 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.getBook = getBook;
exports.getTrending = getTrending;
exports.getPopular = getPopular;
exports.searchBooks = searchBooks;
exports.searchBooksInExtension = searchBooksInExtension;
exports.getChapters = getChapters;
exports.getChapterContent = getChapterContent;
const booksService = __importStar(require("./books.service"));
const extensions_1 = require("../../shared/extensions");
async function getBook(req, reply) {
try {
const { id } = req.params;
const source = req.query.source;
let book;
if (source === 'anilist') {
book = await booksService.getBookById(id);
}
else {
const ext = (0, extensions_1.getExtension)(source);
const result = await booksService.getBookInfoExtension(ext, id);
book = result || null;
}
return book;
}
catch (err) {
return { error: err.message };
}
}
async function getTrending(req, reply) {
try {
const results = await booksService.getTrendingBooks();
return { results };
}
catch (err) {
return { results: [] };
}
}
async function getPopular(req, reply) {
try {
const results = await booksService.getPopularBooks();
return { results };
}
catch (err) {
return { results: [] };
}
}
async function searchBooks(req, reply) {
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;
console.error("Search Error:", error.message);
return { results: [] };
}
}
async function searchBooksInExtension(req, reply) {
try {
const extensionName = req.params.extension;
const query = req.query.q;
const ext = (0, extensions_1.getExtension)(extensionName);
if (!ext)
return { results: [] };
const results = await booksService.searchBooksInExtension(ext, extensionName, query);
return { results };
}
catch (e) {
const error = e;
console.error("Search Error:", error.message);
return { results: [] };
}
}
async function getChapters(req, reply) {
try {
const { id } = req.params;
const source = req.query.source || 'anilist';
const isExternal = source !== 'anilist';
return await booksService.getChaptersForBook(id, isExternal);
}
catch {
return { chapters: [] };
}
}
async function getChapterContent(req, reply) {
try {
const { bookId, chapter, provider } = req.params;
const source = req.query.source || 'anilist';
const content = await booksService.getChapterContent(bookId, chapter, provider, source);
return reply.send(content);
}
catch (err) {
console.error("getChapterContent error:", err.message);
return reply.code(500).send({ error: "Error loading chapter" });
}
}

View File

@@ -0,0 +1,46 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const controller = __importStar(require("./books.controller"));
async function booksRoutes(fastify) {
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);
}
exports.default = booksRoutes;

View File

@@ -0,0 +1,482 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getBookById = getBookById;
exports.getTrendingBooks = getTrendingBooks;
exports.getPopularBooks = getPopularBooks;
exports.searchBooksLocal = searchBooksLocal;
exports.searchBooksAniList = searchBooksAniList;
exports.getBookInfoExtension = getBookInfoExtension;
exports.searchBooksInExtension = searchBooksInExtension;
exports.getChaptersForBook = getChaptersForBook;
exports.getChapterContent = getChapterContent;
const queries_1 = require("../../shared/queries");
const database_1 = require("../../shared/database");
const extensions_1 = require("../../shared/extensions");
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const TTL = 60 * 60 * 6;
const ANILIST_URL = "https://graphql.anilist.co";
async function fetchAniList(query, variables) {
const res = await fetch(ANILIST_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({ query, variables })
});
if (!res.ok) {
throw new Error(`AniList error ${res.status}`);
}
const json = await res.json();
return json?.data;
}
const MEDIA_FIELDS = `
id
title {
romaji
english
native
userPreferred
}
type
format
status
description
startDate { year month day }
endDate { year month day }
season
seasonYear
episodes
chapters
volumes
duration
genres
synonyms
averageScore
popularity
favourites
isAdult
siteUrl
coverImage {
extraLarge
large
medium
color
}
bannerImage
updatedAt
`;
async function getBookById(id) {
const row = await (0, database_1.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?.Media) {
const media = data.data.Media;
const insertSql = `
INSERT INTO books (id, title, updatedAt, full_data)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = EXCLUDED.title,
updatedAt = EXCLUDED.updatedAt,
full_data = EXCLUDED.full_data;
`;
await (0, database_1.run)(insertSql, [
media.id,
media.title?.userPreferred || media.title?.romaji || media.title?.english || null,
media.updatedAt || Math.floor(Date.now() / 1000),
JSON.stringify(media)
]);
return media;
}
}
catch (e) {
console.error("Fetch error:", e);
}
return { error: "Book not found" };
}
async function getTrendingBooks() {
const rows = await (0, database_1.queryAll)("SELECT full_data, updated_at FROM trending_books ORDER BY rank ASC LIMIT 10");
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r) => JSON.parse(r.full_data));
}
}
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: MANGA, sort: TRENDING_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await (0, database_1.queryOne)("DELETE FROM trending_books");
let rank = 1;
for (const book of list) {
await (0, database_1.queryOne)("INSERT INTO trending_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", [rank++, book.id, JSON.stringify(book), now]);
}
return list;
}
async function getPopularBooks() {
const rows = await (0, database_1.queryAll)("SELECT full_data, updated_at FROM popular_books ORDER BY rank ASC LIMIT 10");
if (rows.length) {
const expired = (Date.now() / 1000 - rows[0].updated_at) > TTL;
if (!expired) {
return rows.map((r) => JSON.parse(r.full_data));
}
}
const query = `
query {
Page(page: 1, perPage: 10) {
media(type: MANGA, sort: POPULARITY_DESC) { ${MEDIA_FIELDS} }
}
}
`;
const data = await fetchAniList(query, {});
const list = data?.Page?.media || [];
const now = Math.floor(Date.now() / 1000);
await (0, database_1.queryOne)("DELETE FROM popular_books");
let rank = 1;
for (const book of list) {
await (0, database_1.queryOne)("INSERT INTO popular_books (rank, id, full_data, updated_at) VALUES (?, ?, ?, ?)", [rank++, book.id, JSON.stringify(book), now]);
}
return list;
}
async function searchBooksLocal(query) {
if (!query || query.length < 2) {
return [];
}
const sql = `SELECT full_data FROM books WHERE full_data LIKE ? LIMIT 50`;
const rows = await (0, database_1.queryAll)(sql, [`%${query}%`]);
const results = rows.map((row) => 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);
}
async function searchBooksAniList(query) {
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 [];
}
async function getBookInfoExtension(ext, id) {
if (!ext)
return [];
const extName = ext.constructor.name;
const cached = await (0, queries_1.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 (0, queries_1.cacheExtension)(extName, id, info.title, info);
return info;
}
}
catch (e) {
console.error(`Extension getInfo failed:`, e);
}
}
return [];
}
async function searchBooksInExtension(ext, name, query) {
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?.length) {
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) {
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, name, searchTitle, search, origin) {
const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${searchTitle}`;
const cached = await (0, queries_1.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);
}
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;
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 = chaps.map((ch) => ({
id: ch.id,
number: parseFloat(ch.number.toString()),
title: ch.title,
date: ch.releaseDate,
provider: name,
index: ch.index
}));
await (0, queries_1.setCache)(cacheKey, result, CACHE_TTL_MS);
return result;
}
catch (e) {
const error = e;
console.error(`Failed to fetch chapters from ${name}:`, error.message);
return [];
}
}
async function getChaptersForBook(id, ext, onlyProvider) {
let bookData = null;
let searchTitle = "";
if (!ext) {
const result = await getBookById(id);
if (!result || "error" in result)
return { chapters: [] };
bookData = result;
const titles = [bookData.title.english, bookData.title.romaji].filter(Boolean);
searchTitle = titles[0];
}
const bookExtensions = (0, extensions_1.getBookExtensionsMap)();
let extension;
if (!searchTitle) {
for (const [name, ext] of bookExtensions) {
const title = await (0, queries_1.getExtensionTitle)(name, id);
if (title) {
searchTitle = title;
extension = name;
}
}
}
const allChapters = [];
let exts = "anilist";
if (ext)
exts = "ext";
for (const [name, ext] of bookExtensions) {
if (onlyProvider && name !== onlyProvider)
continue;
if (name == extension) {
const chapters = await searchChaptersInExtension(ext, name, id, false, exts);
allChapters.push(...chapters);
}
else {
const chapters = await searchChaptersInExtension(ext, name, searchTitle, true, exts);
allChapters.push(...chapters);
}
}
return {
chapters: allChapters.sort((a, b) => Number(a.number) - Number(b.number))
};
}
async function getChapterContent(bookId, chapterIndex, providerName, source) {
const extensions = (0, extensions_1.getAllExtensions)();
const ext = extensions.get(providerName);
if (!ext) {
throw new Error("Provider not found");
}
const contentCacheKey = `content:${providerName}:${source}:${bookId}:${chapterIndex}`;
const cachedContent = await (0, queries_1.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);
}
catch (e) {
console.error(`[${providerName}] Error parsing cached content:`, e);
}
}
else {
console.log(`[${providerName}] Content cache expired for Book ID ${bookId}, Index ${chapterIndex}`);
}
}
const isExternal = source !== 'anilist';
const chapterList = await getChaptersForBook(bookId, isExternal, providerName);
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;
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 (0, queries_1.setCache)(contentCacheKey, contentResult, CACHE_TTL_MS);
return contentResult;
}
catch (err) {
const error = err;
console.error(`[Chapter] Error loading from ${providerName}:`, error.message);
throw err;
}
}

View File

@@ -0,0 +1,71 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getExtensions = getExtensions;
exports.getAnimeExtensions = getAnimeExtensions;
exports.getBookExtensions = getBookExtensions;
exports.getGalleryExtensions = getGalleryExtensions;
exports.getExtensionSettings = getExtensionSettings;
exports.installExtension = installExtension;
exports.uninstallExtension = uninstallExtension;
const extensions_1 = require("../../shared/extensions");
async function getExtensions(req, reply) {
return { extensions: (0, extensions_1.getExtensionsList)() };
}
async function getAnimeExtensions(req, reply) {
const animeExtensions = (0, extensions_1.getAnimeExtensionsMap)();
return { extensions: Array.from(animeExtensions.keys()) };
}
async function getBookExtensions(req, reply) {
const bookExtensions = (0, extensions_1.getBookExtensionsMap)();
return { extensions: Array.from(bookExtensions.keys()) };
}
async function getGalleryExtensions(req, reply) {
const galleryExtensions = (0, extensions_1.getGalleryExtensionsMap)();
return { extensions: Array.from(galleryExtensions.keys()) };
}
async function getExtensionSettings(req, reply) {
const { name } = req.params;
const ext = (0, extensions_1.getExtension)(name);
if (!ext) {
return { error: "Extension not found" };
}
if (!ext.getSettings) {
return { episodeServers: ["default"], supportsDub: false };
}
return ext.getSettings();
}
async function installExtension(req, reply) {
const { fileName } = req.body;
if (!fileName || !fileName.endsWith('.js')) {
return reply.code(400).send({ error: "Invalid extension fileName provided" });
}
try {
const downloadUrl = `https://git.waifuboard.app/ItsSkaiya/WaifuBoard-Extensions/raw/branch/main/${fileName}`;
await (0, extensions_1.saveExtensionFile)(fileName, downloadUrl);
req.server.log.info(`Extension installed: ${fileName}`);
return reply.code(200).send({ success: true, message: `Extension ${fileName} installed successfully.` });
}
catch (error) {
req.server.log.error(`Failed to install extension ${fileName}:`, error);
return reply.code(500).send({ success: false, error: `Failed to install extension ${fileName}.` });
}
}
async function uninstallExtension(req, reply) {
const { fileName } = req.body;
if (!fileName || !fileName.endsWith('.js')) {
return reply.code(400).send({ error: "Invalid extension fileName provided" });
}
try {
await (0, extensions_1.deleteExtensionFile)(fileName);
req.server.log.info(`Extension uninstalled: ${fileName}`);
return reply.code(200).send({ success: true, message: `Extension ${fileName} uninstalled successfully.` });
}
catch (error) {
// @ts-ignore
if (error.code === 'ENOENT') {
return reply.code(200).send({ success: true, message: `Extension ${fileName} already uninstalled (file not found).` });
}
req.server.log.error(`Failed to uninstall extension ${fileName}:`, error);
return reply.code(500).send({ success: false, error: `Failed to uninstall extension ${fileName}.` });
}
}

View File

@@ -0,0 +1,46 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const controller = __importStar(require("./extensions.controller"));
async function extensionsRoutes(fastify) {
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);
fastify.post('/extensions/install', controller.installExtension);
fastify.post('/extensions/uninstall', controller.uninstallExtension);
}
exports.default = extensionsRoutes;

View File

@@ -0,0 +1,172 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.search = search;
exports.searchInExtension = searchInExtension;
exports.getInfo = getInfo;
exports.getFavorites = getFavorites;
exports.getFavoriteById = getFavoriteById;
exports.addFavorite = addFavorite;
exports.removeFavorite = removeFavorite;
const galleryService = __importStar(require("./gallery.service"));
async function search(req, reply) {
try {
const query = req.query.q || '';
const page = parseInt(req.query.page) || 1;
const perPage = parseInt(req.query.perPage) || 48;
return await galleryService.searchGallery(query, page, perPage);
}
catch (err) {
const error = err;
console.error("Gallery Search Error:", error.message);
return {
results: [],
total: 0,
page: 1,
hasNextPage: false
};
}
}
async function searchInExtension(req, reply) {
try {
const provider = req.query.provider;
const query = req.query.q || '';
const page = parseInt(req.query.page) || 1;
const perPage = parseInt(req.query.perPage) || 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.message);
return {
results: [],
total: 0,
page: 1,
hasNextPage: false
};
}
}
async function getInfo(req, reply) {
try {
const { id } = req.params;
const provider = req.query.provider;
return await galleryService.getGalleryInfo(id, provider);
}
catch (err) {
const error = err;
console.error("Gallery Info Error:", error.message);
return reply.code(404).send({ error: "Gallery item not found" });
}
}
async function getFavorites(req, reply) {
try {
if (!req.user)
return reply.code(401).send({ error: "Unauthorized" });
const favorites = await galleryService.getFavorites(req.user.id);
return { favorites };
}
catch (err) {
console.error("Get Favorites Error:", err.message);
return reply.code(500).send({ error: "Failed to retrieve favorites" });
}
}
async function getFavoriteById(req, reply) {
try {
if (!req.user)
return reply.code(401).send({ error: "Unauthorized" });
const { id } = req.params;
const favorite = await galleryService.getFavoriteById(id, req.user.id);
if (!favorite) {
return reply.code(404).send({ error: "Favorite not found" });
}
return { favorite };
}
catch (err) {
console.error("Get Favorite By ID Error:", err.message);
return reply.code(500).send({ error: "Failed to retrieve favorite" });
}
}
async function addFavorite(req, reply) {
try {
if (!req.user)
return reply.code(401).send({ error: "Unauthorized" });
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"
});
}
const result = await galleryService.addFavorite({
id,
user_id: req.user.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.message);
return reply.code(500).send({ error: "Failed to add favorite" });
}
}
async function removeFavorite(req, reply) {
try {
if (!req.user)
return reply.code(401).send({ error: "Unauthorized" });
const { id } = req.params;
const result = await galleryService.removeFavorite(id, req.user.id);
if (result.success) {
return { success: true, message: "Favorite removed successfully" };
}
else {
return reply.code(404).send({ error: "Favorite not found" });
}
}
catch (err) {
console.error("Remove Favorite Error:", err.message);
return reply.code(500).send({ error: "Failed to remove favorite" });
}
}

View File

@@ -0,0 +1,46 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const controller = __importStar(require("./gallery.controller"));
async function galleryRoutes(fastify) {
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);
}
exports.default = galleryRoutes;

View File

@@ -0,0 +1,176 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.searchGallery = searchGallery;
exports.getGalleryInfo = getGalleryInfo;
exports.searchInExtension = searchInExtension;
exports.getFavorites = getFavorites;
exports.getFavoriteById = getFavoriteById;
exports.addFavorite = addFavorite;
exports.removeFavorite = removeFavorite;
const extensions_1 = require("../../shared/extensions");
const database_1 = require("../../shared/database");
async function searchGallery(query, page = 1, perPage = 48) {
const extensions = (0, extensions_1.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: []
};
}
async function getGalleryInfo(id, providerName) {
const extensions = (0, extensions_1.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;
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");
}
async function searchInExtension(providerName, query, page = 1, perPage = 48) {
const ext = (0, extensions_1.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) => ({
...r,
provider: providerName
}));
return {
...results,
results: enrichedResults
};
}
catch (e) {
const error = e;
console.error(`[Gallery] Search failed in ${providerName}:`, error.message);
return {
total: 0,
next: 0,
previous: 0,
pages: 0,
page,
hasNextPage: false,
results: []
};
}
}
async function getFavorites(userId) {
const db = (0, database_1.getDatabase)("favorites");
return new Promise((resolve) => {
db.all('SELECT * FROM favorites WHERE user_id = ?', [userId], (err, rows) => {
if (err) {
console.error('Error getting favorites:', err.message);
resolve([]);
}
else {
resolve(rows);
}
});
});
}
async function getFavoriteById(id, userId) {
const db = (0, database_1.getDatabase)("favorites");
return new Promise((resolve) => {
db.get('SELECT * FROM favorites WHERE id = ? AND user_id = ?', [id, userId], (err, row) => {
if (err) {
console.error('Error getting favorite by id:', err.message);
resolve(null);
}
else {
resolve(row || null);
}
});
});
}
async function addFavorite(fav) {
const db = (0, database_1.getDatabase)("favorites");
return new Promise((resolve) => {
const stmt = `
INSERT INTO favorites (id, user_id, title, image_url, thumbnail_url, tags, headers, provider)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`;
db.run(stmt, [
fav.id,
fav.user_id,
fav.title,
fav.image_url,
fav.thumbnail_url,
fav.tags || "",
fav.headers || "",
fav.provider || ""
], function (err) {
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 });
}
});
});
}
async function removeFavorite(id, userId) {
const db = (0, database_1.getDatabase)("favorites");
return new Promise((resolve) => {
const stmt = 'DELETE FROM favorites WHERE id = ? AND user_id = ?';
db.run(stmt, [id, userId], function (err) {
if (err) {
console.error('Error removing favorite:', err.message);
resolve({ success: false, error: err.message });
}
else {
// @ts-ignore
resolve({ success: this.changes > 0 });
}
});
});
}

View File

@@ -0,0 +1,158 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.getList = getList;
exports.getSingleEntry = getSingleEntry;
exports.upsertEntry = upsertEntry;
exports.deleteEntry = deleteEntry;
exports.getListByFilter = getListByFilter;
const listService = __importStar(require("./list.service"));
async function getList(req, reply) {
const userId = req.user?.id;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
try {
const results = await listService.getUserList(userId);
return { results };
}
catch (err) {
console.error(err);
return reply.code(500).send({ error: "Failed to retrieve list" });
}
}
async function getSingleEntry(req, reply) {
const userId = req.user?.id;
const { entryId } = req.params;
const { source, entry_type } = req.query;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
if (!entryId || !source || !entry_type) {
return reply.code(400).send({ error: "Missing required identifier: entryId, source, or entry_type." });
}
try {
const entry = await listService.getSingleListEntry(userId, entryId, source, entry_type);
if (!entry) {
return reply.code(404).send({ found: false, message: "Entry not found in user list." });
}
return { found: true, entry: entry };
}
catch (err) {
console.error(err);
return reply.code(500).send({ error: "Failed to retrieve list entry" });
}
}
async function upsertEntry(req, reply) {
const userId = req.user?.id;
const body = req.body;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
if (!body.entry_id || !body.source || !body.status || !body.entry_type) {
return reply.code(400).send({
error: "Missing required fields (entry_id, source, status, entry_type)."
});
}
try {
const entryData = {
user_id: userId,
entry_id: body.entry_id,
external_id: body.external_id,
source: body.source,
entry_type: body.entry_type,
status: body.status,
progress: body.progress || 0,
score: body.score || null,
start_date: body.start_date || null,
end_date: body.end_date || null,
repeat_count: body.repeat_count ?? 0,
notes: body.notes || null,
is_private: body.is_private ?? 0
};
const result = await listService.upsertListEntry(entryData);
return { success: true, changes: result.changes };
}
catch (err) {
console.error(err);
return reply.code(500).send({ error: "Failed to save list entry" });
}
}
async function deleteEntry(req, reply) {
const userId = req.user?.id;
const { entryId } = req.params;
const { source } = req.query; // ✅ VIENE DEL FRONT
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
if (!entryId || !source) {
return reply.code(400).send({ error: "Missing entryId or source." });
}
try {
const result = await listService.deleteListEntry(userId, entryId, source);
if (result.success) {
return { success: true, external: result.external };
}
else {
return reply.code(404).send({
error: "Entry not found or unauthorized to delete."
});
}
}
catch (err) {
console.error(err);
return reply.code(500).send({ error: "Failed to delete list entry" });
}
}
async function getListByFilter(req, reply) {
const userId = req.user?.id;
const { status, entry_type } = req.query;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
if (!status && !entry_type) {
return reply.code(400).send({
error: "At least one filter is required (status or entry_type)."
});
}
try {
const results = await listService.getUserListByFilter(userId, status, entry_type);
return { results };
}
catch (err) {
console.error(err);
return reply.code(500).send({ error: "Failed to retrieve filtered list" });
}
}

View File

@@ -0,0 +1,44 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const controller = __importStar(require("./list.controller"));
async function listRoutes(fastify) {
fastify.get('/list', controller.getList);
fastify.get('/list/entry/:entryId', controller.getSingleEntry);
fastify.post('/list/entry', controller.upsertEntry);
fastify.delete('/list/entry/:entryId', controller.deleteEntry);
fastify.get('/list/filter', controller.getListByFilter);
}
exports.default = listRoutes;

View File

@@ -0,0 +1,491 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.upsertListEntry = upsertListEntry;
exports.getUserList = getUserList;
exports.deleteListEntry = deleteListEntry;
exports.getSingleListEntry = getSingleListEntry;
exports.getActiveAccessToken = getActiveAccessToken;
exports.isConnected = isConnected;
exports.getUserListByFilter = getUserListByFilter;
const database_1 = require("../../shared/database");
const extensions_1 = require("../../shared/extensions");
const animeService = __importStar(require("../anime/anime.service"));
const booksService = __importStar(require("../books/books.service"));
const aniListService = __importStar(require("../anilist/anilist.service"));
const USER_DB = 'userdata';
async function upsertListEntry(entry) {
const { user_id, entry_id, source, entry_type, status, progress, score, start_date, end_date, repeat_count, notes, is_private } = entry;
let prev = null;
try {
prev = await getSingleListEntry(user_id, entry_id, source, entry_type);
}
catch {
prev = null;
}
const isNew = !prev;
if (!isNew && prev?.progress != null && progress < prev.progress) {
return { changes: 0, ignored: true };
}
const today = new Date().toISOString().slice(0, 10);
if (prev?.start_date && !entry.start_date) {
entry.start_date = prev.start_date;
}
if (!prev?.start_date && progress === 1) {
entry.start_date = today;
}
const total = prev?.total_episodes ??
prev?.total_chapters ??
null;
if (total && progress >= total) {
entry.status = 'COMPLETED';
entry.end_date = today;
}
if (source === 'anilist') {
const token = await getActiveAccessToken(user_id);
if (token) {
try {
const result = await aniListService.updateAniListEntry(token, {
mediaId: entry.entry_id,
status: entry.status,
progress: entry.progress,
score: entry.score,
start_date: entry.start_date,
end_date: entry.end_date,
repeat_count: entry.repeat_count,
notes: entry.notes,
is_private: entry.is_private
});
return { changes: 0, external: true, anilistResult: result };
}
catch (err) {
console.error("Error actualizando AniList:", err);
}
}
}
const sql = `
INSERT INTO ListEntry
(
user_id, entry_id, source, entry_type, status,
progress, score,
start_date, end_date, repeat_count, notes, is_private,
updated_at
)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, entry_id) DO UPDATE SET
source = EXCLUDED.source,
entry_type = EXCLUDED.entry_type,
status = EXCLUDED.status,
progress = EXCLUDED.progress,
score = EXCLUDED.score,
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
repeat_count = EXCLUDED.repeat_count,
notes = EXCLUDED.notes,
is_private = EXCLUDED.is_private,
updated_at = CURRENT_TIMESTAMP;
`;
const params = [
entry.user_id,
entry.entry_id,
entry.source,
entry.entry_type,
entry.status,
entry.progress,
entry.score ?? null,
entry.start_date || null,
entry.end_date || null,
entry.repeat_count ?? 0,
entry.notes || null,
entry.is_private ?? 0
];
try {
const result = await (0, database_1.run)(sql, params, USER_DB);
return { changes: result.changes, lastID: result.lastID, external: false };
}
catch (error) {
console.error("Error al guardar la entrada de lista:", error);
throw new Error("Error en la base de datos al guardar la entrada.");
}
}
async function getUserList(userId) {
const sql = `
SELECT * FROM ListEntry
WHERE user_id = ?
ORDER BY updated_at DESC;
`;
try {
const dbList = await (0, database_1.queryAll)(sql, [userId], USER_DB);
const connected = await isConnected(userId);
let finalList = [...dbList];
if (connected) {
const anilistEntries = await aniListService.getUserAniList(userId);
const localWithoutAnilist = dbList.filter(entry => entry.source !== 'anilist');
finalList = [...anilistEntries, ...localWithoutAnilist];
}
const enrichedListPromises = finalList.map(async (entry) => {
if (entry.source === 'anilist' && connected) {
let finalTitle = entry.title;
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: entry.poster || 'https://placehold.co/400x600?text=No+Cover',
};
}
let contentDetails = null;
const id = entry.entry_id;
const type = entry.entry_type;
const ext = (0, extensions_1.getExtension)(entry.source);
try {
if (type === 'ANIME') {
if (entry.source === 'anilist') {
const anime = await animeService.getAnimeById(id);
contentDetails = {
title: anime?.title.english || 'Unknown Anime Title',
poster: anime?.coverImage?.extraLarge || '',
total_episodes: anime?.episodes || 0,
};
}
else {
const anime = await animeService.getAnimeInfoExtension(ext, id.toString());
contentDetails = {
title: anime?.title || 'Unknown Anime Title',
poster: anime?.image || 'https://placehold.co/400x600?text=No+Cover',
total_episodes: anime?.episodes || 0,
};
}
}
else if (type === 'MANGA' || type === 'NOVEL') {
if (entry.source === 'anilist') {
const book = await booksService.getBookById(id);
contentDetails = {
title: book?.title.english || 'Unknown Book Title',
poster: book?.coverImage?.extraLarge || 'https://placehold.co/400x600?text=No+Cover',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
else {
const book = await booksService.getBookInfoExtension(ext, id.toString());
contentDetails = {
title: book?.title || 'Unknown Book Title',
poster: book?.image || '',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
}
}
catch {
contentDetails = {
title: 'Error Loading Details',
poster: 'https://placehold.co/400x600?text=No+Cover',
};
}
let finalTitle = contentDetails?.title || 'Unknown Title';
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.total_episodes,
total_chapters: contentDetails?.total_chapters,
};
});
return await Promise.all(enrichedListPromises);
}
catch (error) {
console.error("Error al obtener la lista del usuario:", error);
throw new Error("Error getting list.");
}
}
async function deleteListEntry(userId, entryId, source) {
if (source === 'anilist') {
const token = await getActiveAccessToken(userId);
if (token) {
try {
await aniListService.deleteAniListEntry(token, Number(entryId));
return { success: true, external: true };
}
catch (err) {
console.error("Error borrando en AniList:", err);
}
}
}
const sql = `
DELETE FROM ListEntry
WHERE user_id = ? AND entry_id = ?;
`;
const result = await (0, database_1.run)(sql, [userId, entryId], USER_DB);
return { success: result.changes > 0, changes: result.changes, external: false };
}
async function getSingleListEntry(userId, entryId, source, entryType) {
const localSql = `
SELECT * FROM ListEntry
WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?;
`;
const localResult = await (0, database_1.queryAll)(localSql, [userId, entryId, source, entryType], USER_DB);
if (localResult.length > 0) {
const entry = localResult[0];
const contentDetails = entryType === 'ANIME'
? await animeService.getAnimeById(entryId).catch(() => null)
: await booksService.getBookById(entryId).catch(() => null);
let finalTitle = contentDetails?.title || 'Unknown';
let finalPoster = contentDetails?.coverImage?.extraLarge ||
contentDetails?.image ||
'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object') {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown';
}
return {
...entry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.episodes,
total_chapters: contentDetails?.chapters,
};
}
if (source === 'anilist') {
const connected = await isConnected(userId);
if (!connected)
return null;
const sql = `
SELECT access_token
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
const integration = await (0, database_1.queryOne)(sql, [userId], USER_DB);
if (!integration?.access_token)
return null;
if (entryType === 'NOVEL') {
entryType = 'MANGA';
}
const aniEntry = await aniListService.getSingleAniListEntry(integration.access_token, Number(entryId), entryType);
if (!aniEntry)
return null;
const contentDetails = entryType === 'ANIME'
? await animeService.getAnimeById(entryId).catch(() => null)
: await booksService.getBookById(entryId).catch(() => null);
let finalTitle = contentDetails?.title || 'Unknown';
let finalPoster = contentDetails?.coverImage?.extraLarge ||
contentDetails?.image ||
'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object') {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown';
}
return {
user_id: userId,
...aniEntry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.episodes,
total_chapters: contentDetails?.chapters,
};
}
return null;
}
async function getActiveAccessToken(userId) {
const sql = `
SELECT access_token, expires_at
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
try {
const integration = await (0, database_1.queryOne)(sql, [userId], USER_DB);
if (!integration) {
return null;
}
const expiryDate = new Date(integration.expires_at);
const now = new Date();
const fiveMinutes = 5 * 60 * 1000;
if (expiryDate.getTime() < (now.getTime() + fiveMinutes)) {
console.log(`AniList token for user ${userId} expired or near expiry.`);
return null;
}
return integration.access_token;
}
catch (error) {
console.error("Error al verificar la integración de AniList:", error);
return null;
}
}
async function isConnected(userId) {
const token = await getActiveAccessToken(userId);
return !!token;
}
async function getUserListByFilter(userId, status, entryType) {
let sql = `
SELECT * FROM ListEntry
WHERE user_id = ?
ORDER BY updated_at DESC;
`;
const params = [userId];
try {
const dbList = await (0, database_1.queryAll)(sql, params, USER_DB);
const connected = await isConnected(userId);
const statusMap = {
watching: 'CURRENT',
reading: 'CURRENT',
completed: 'COMPLETED',
paused: 'PAUSED',
dropped: 'DROPPED',
planning: 'PLANNING'
};
const mappedStatus = status ? statusMap[status.toLowerCase()] : null;
let finalList = [];
const filteredLocal = dbList.filter((entry) => {
if (mappedStatus && entry.status !== mappedStatus)
return false;
if (entryType) {
if (entryType === 'MANGA') {
if (!['MANGA', 'NOVEL'].includes(entry.entry_type))
return false;
}
else {
if (entry.entry_type !== entryType)
return false;
}
}
return true;
});
let filteredAniList = [];
if (connected) {
const anilistEntries = await aniListService.getUserAniList(userId);
filteredAniList = anilistEntries.filter((entry) => {
if (mappedStatus && entry.status !== mappedStatus)
return false;
if (entryType) {
if (entryType === 'MANGA') {
if (!['MANGA', 'NOVEL'].includes(entry.entry_type))
return false;
}
else {
if (entry.entry_type !== entryType)
return false;
}
}
return true;
});
}
finalList = [...filteredAniList, ...filteredLocal];
const enrichedListPromises = finalList.map(async (entry) => {
if (entry.source === 'anilist') {
let finalTitle = entry.title;
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: entry.poster || 'https://placehold.co/400x600?text=No+Cover',
};
}
let contentDetails = null;
const id = entry.entry_id;
const type = entry.entry_type;
const ext = (0, extensions_1.getExtension)(entry.source);
try {
if (type === 'ANIME') {
const anime = await animeService.getAnimeInfoExtension(ext, id.toString());
contentDetails = {
title: anime?.title || 'Unknown Anime Title',
poster: anime?.image || '',
total_episodes: anime?.episodes || 0,
};
}
else if (type === 'MANGA' || type === 'NOVEL') {
const book = await booksService.getBookInfoExtension(ext, id.toString());
contentDetails = {
title: book?.title || 'Unknown Book Title',
poster: book?.image || '',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
}
catch {
contentDetails = {
title: 'Error Loading Details',
poster: 'https://placehold.co/400x600?text=No+Cover',
};
}
let finalTitle = contentDetails?.title || 'Unknown Title';
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.total_episodes,
total_chapters: contentDetails?.total_chapters,
};
});
return await Promise.all(enrichedListPromises);
}
catch (error) {
console.error("Error al filtrar la lista del usuario:", error);
throw new Error("Error en la base de datos al obtener la lista filtrada.");
}
}

View File

@@ -0,0 +1,48 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.handleProxy = handleProxy;
const proxy_service_1 = require("./proxy.service");
async function handleProxy(req, reply) {
const { url, referer, origin, userAgent } = req.query;
if (!url) {
return reply.code(400).send({ error: "No URL provided" });
}
try {
const { response, contentType, isM3U8, contentLength } = await (0, proxy_service_1.proxyRequest)(url, {
referer,
origin,
userAgent
});
reply.header('Access-Control-Allow-Origin', '*');
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
reply.header('Access-Control-Allow-Headers', 'Content-Type, Range');
reply.header('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Accept-Ranges');
if (contentType) {
reply.header('Content-Type', contentType);
}
if (contentLength) {
reply.header('Content-Length', contentLength);
}
if (contentType?.startsWith('image/') || contentType?.startsWith('video/')) {
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
}
reply.header('Accept-Ranges', 'bytes');
if (isM3U8) {
const text = await response.text();
const baseUrl = new URL(response.url);
const processedContent = (0, proxy_service_1.processM3U8Content)(text, baseUrl, {
referer,
origin,
userAgent
});
return reply.send(processedContent);
}
return reply.send((0, proxy_service_1.streamToReadable)(response.body));
}
catch (err) {
req.server.log.error(err);
if (!reply.sent) {
return reply.code(500).send({ error: "Internal Server Error" });
}
}
}

View File

@@ -0,0 +1,7 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const proxy_controller_1 = require("./proxy.controller");
async function proxyRoutes(fastify) {
fastify.get('/proxy', proxy_controller_1.handleProxy);
}
exports.default = proxyRoutes;

View File

@@ -0,0 +1,111 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.proxyRequest = proxyRequest;
exports.processM3U8Content = processM3U8Content;
exports.streamToReadable = streamToReadable;
const stream_1 = require("stream");
async function proxyRequest(url, { referer, origin, userAgent }) {
const headers = {
'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',
'Accept-Encoding': 'identity',
'Connection': 'keep-alive'
};
if (referer)
headers['Referer'] = referer;
if (origin)
headers['Origin'] = origin;
let lastError = null;
const maxRetries = 2;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000);
const response = await fetch(url, {
headers,
redirect: 'follow',
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 404 || response.status === 403) {
throw new Error(`Proxy Error: ${response.status} ${response.statusText}`);
}
if (attempt < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
continue;
}
throw new Error(`Proxy Error: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
const contentLength = response.headers.get('content-length');
const isM3U8 = (contentType && contentType.includes('mpegurl')) || url.includes('.m3u8');
return {
response,
contentType,
isM3U8,
contentLength
};
}
catch (error) {
lastError = error;
if (attempt === maxRetries - 1) {
throw lastError;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
}
throw lastError || new Error('Unknown error in proxyRequest');
}
function processM3U8Content(text, baseUrl, { referer, origin, userAgent }) {
return text.replace(/^(?!#)(?!\s*$).+/gm, (line) => {
line = line.trim();
let absoluteUrl;
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()}`;
});
}
function streamToReadable(webStream) {
const reader = webStream.getReader();
let readTimeout;
return new stream_1.Readable({
async read() {
try {
const timeoutPromise = new Promise((_, reject) => {
readTimeout = setTimeout(() => reject(new Error('Stream read timeout')), 10000);
});
const readPromise = reader.read();
const { done, value } = await Promise.race([readPromise, timeoutPromise]);
clearTimeout(readTimeout);
if (done) {
this.push(null);
}
else {
this.push(Buffer.from(value));
}
}
catch (error) {
clearTimeout(readTimeout);
this.destroy(error);
}
},
destroy(error, callback) {
clearTimeout(readTimeout);
reader.cancel().then(() => callback(error)).catch(callback);
}
});
}

View File

@@ -0,0 +1,102 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.initRPC = initRPC;
exports.setActivity = setActivity;
// @ts-ignore
const discord_rpc_1 = require("@ryuziii/discord-rpc");
let rpcClient = null;
let reconnectTimer = null;
let connected = false;
function attemptReconnect(clientId) {
connected = false;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
console.log('Discord RPC: Trying to reconnect...');
reconnectTimer = setTimeout(() => {
initRPC(clientId);
}, 10000);
}
function initRPC(clientId) {
if (rpcClient) {
try {
rpcClient.destroy();
}
catch (e) { }
rpcClient = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
console.log(`Discord RPC: Starting with id ...${clientId.slice(-4)}`);
try {
rpcClient = new discord_rpc_1.DiscordRPCClient({
clientId: clientId,
transport: 'ipc'
});
}
catch (err) {
console.error('Discord RPC:', err);
return;
}
rpcClient.on("ready", () => {
connected = true;
const user = rpcClient?.user ? rpcClient.user.username : 'User';
console.log(`Discord RPC: Authenticated for: ${user}`);
setTimeout(() => {
setActivity({ details: "Browsing", state: "In App", mode: "idle" });
}, 1000);
});
rpcClient.on('disconnected', () => {
console.log('Discord RPC: Desconexión detectada.');
attemptReconnect(clientId);
});
rpcClient.on('error', (err) => {
console.error('[Discord RPC] Error:', err.message);
if (connected) {
attemptReconnect(clientId);
}
});
try {
rpcClient.connect().catch((err) => {
console.error('Discord RPC: Error al conectar', err.message);
attemptReconnect(clientId);
});
}
catch (err) {
console.error('Discord RPC: Error al iniciar la conexión', err);
attemptReconnect(clientId);
}
}
function setActivity(data = {}) {
if (!rpcClient || !connected)
return;
let type;
let state = data.state;
let details = data.details;
if (data.mode === "watching") {
type = 3;
}
else if (data.mode === "reading") {
type = 0;
}
else {
type = 0;
}
try {
rpcClient.setActivity({
details: details,
state: state,
type: type,
startTimestamp: new Date(),
largeImageKey: "bigpicture",
largeImageText: "v2.0.0",
instance: false
});
}
catch (error) {
console.error("Discord RPC: Failed to set activity", error);
}
}

View File

@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.init = init;
exports.setRPC = setRPC;
const rp_service_1 = require("./rp.service");
let initialized = false;
function init() {
if (!initialized) {
(0, rp_service_1.initRPC)(process.env.DISCORD_CLIENT_ID);
initialized = true;
}
}
async function setRPC(request, reply) {
const { details, state, mode } = request.body;
(0, rp_service_1.setActivity)({
details,
state,
mode
});
return reply.send({ ok: true });
}

View File

@@ -0,0 +1,40 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const controller = __importStar(require("./rpc.controller"));
async function rpcRoutes(fastify) {
fastify.post("/rpc", controller.setRPC);
}
exports.default = rpcRoutes;

2
electron/api/types.js Normal file
View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,247 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getMe = getMe;
exports.login = login;
exports.getAllUsers = getAllUsers;
exports.createUser = createUser;
exports.getUser = getUser;
exports.updateUser = updateUser;
exports.deleteUser = deleteUser;
exports.getIntegrationStatus = getIntegrationStatus;
exports.disconnectAniList = disconnectAniList;
exports.changePassword = changePassword;
const userService = __importStar(require("./user.service"));
const database_1 = require("../../shared/database");
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
async function getMe(req, reply) {
const userId = req.user?.id;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
const user = await (0, database_1.queryOne)(`SELECT username, profile_picture_url FROM User WHERE id = ?`, [userId], 'userdata');
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
return reply.send({
username: user.username,
avatar: user.profile_picture_url
});
}
async function login(req, reply) {
const { userId, password } = req.body;
if (!userId || typeof userId !== "number" || userId <= 0) {
return reply.code(400).send({ error: "Invalid userId provided" });
}
const user = await userService.getUserById(userId);
if (!user) {
return reply.code(404).send({ error: "User not found in local database" });
}
// Si el usuario tiene contraseña, debe proporcionarla
if (user.has_password) {
if (!password) {
return reply.code(401).send({
error: "Password required",
requiresPassword: true
});
}
const isValid = await userService.verifyPassword(userId, password);
if (!isValid) {
return reply.code(401).send({ error: "Incorrect password" });
}
}
const token = jsonwebtoken_1.default.sign({ id: userId }, process.env.JWT_SECRET, { expiresIn: "7d" });
return reply.code(200).send({
success: true,
token
});
}
async function getAllUsers(req, reply) {
try {
const users = await userService.getAllUsers();
return { users };
}
catch (err) {
console.error("Get All Users Error:", err.message);
return reply.code(500).send({ error: "Failed to retrieve user list" });
}
}
async function createUser(req, reply) {
try {
const { username, profilePictureUrl, password } = req.body;
if (!username) {
return reply.code(400).send({ error: "Missing required field: username" });
}
const result = await userService.createUser(username, profilePictureUrl, password);
return reply.code(201).send({
success: true,
userId: result.lastID,
username
});
}
catch (err) {
if (err.message.includes('SQLITE_CONSTRAINT')) {
return reply.code(409).send({ error: "Username already exists." });
}
console.error("Create User Error:", err.message);
return reply.code(500).send({ error: "Failed to create user" });
}
}
async function getUser(req, reply) {
try {
const { id } = req.params;
const userId = parseInt(id, 10);
const user = await userService.getUserById(userId);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
return { user };
}
catch (err) {
console.error("Get User Error:", err.message);
return reply.code(500).send({ error: "Failed to retrieve user" });
}
}
async function updateUser(req, reply) {
try {
const { id } = req.params;
const userId = parseInt(id, 10);
const updates = req.body;
if (Object.keys(updates).length === 0) {
return reply.code(400).send({ error: "No update fields provided" });
}
const result = await userService.updateUser(userId, updates);
if (result && result.changes > 0) {
return { success: true, message: "User updated successfully" };
}
else {
return reply.code(404).send({ error: "User not found or nothing to update" });
}
}
catch (err) {
if (err.message.includes('SQLITE_CONSTRAINT')) {
return reply.code(409).send({ error: "Username already exists or is invalid." });
}
console.error("Update User Error:", err.message);
return reply.code(500).send({ error: "Failed to update user" });
}
}
async function deleteUser(req, reply) {
try {
const { id } = req.params;
const userId = parseInt(id, 10);
if (!userId || isNaN(userId)) {
return reply.code(400).send({ error: "Invalid user id" });
}
const result = await userService.deleteUser(userId);
if (result && result.changes > 0) {
return { success: true, message: "User deleted successfully" };
}
else {
return reply.code(404).send({ error: "User not found" });
}
}
catch (err) {
console.error("Delete User Error:", err.message);
return reply.code(500).send({ error: "Failed to delete user" });
}
}
async function getIntegrationStatus(req, reply) {
try {
const { id } = req.params;
const userId = parseInt(id, 10);
if (!userId || isNaN(userId)) {
return reply.code(400).send({ error: "Invalid user id" });
}
const integration = await userService.getAniListIntegration(userId);
return reply.code(200).send(integration);
}
catch (err) {
console.error("Get Integration Status Error:", err.message);
return reply.code(500).send({ error: "Failed to check integration status" });
}
}
async function disconnectAniList(req, reply) {
try {
const { id } = req.params;
const userId = parseInt(id, 10);
if (!userId || isNaN(userId)) {
return reply.code(400).send({ error: "Invalid user id" });
}
const result = await userService.removeAniListIntegration(userId);
if (result.changes === 0) {
return reply.code(404).send({ error: "AniList integration not found" });
}
return reply.send({ success: true });
}
catch (err) {
console.error("Disconnect AniList Error:", err);
return reply.code(500).send({ error: "Failed to disconnect AniList" });
}
}
async function changePassword(req, reply) {
try {
const { id } = req.params;
const { currentPassword, newPassword } = req.body;
const userId = parseInt(id, 10);
if (!userId || isNaN(userId)) {
return reply.code(400).send({ error: "Invalid user id" });
}
const user = await userService.getUserById(userId);
if (!user) {
return reply.code(404).send({ error: "User not found" });
}
// Si el usuario tiene contraseña actual, debe proporcionar la contraseña actual
if (user.has_password && currentPassword) {
const isValid = await userService.verifyPassword(userId, currentPassword);
if (!isValid) {
return reply.code(401).send({ error: "Current password is incorrect" });
}
}
// Actualizar la contraseña (null para eliminarla, string para establecerla)
await userService.updateUser(userId, { password: newPassword });
return reply.send({
success: true,
message: newPassword ? "Password updated successfully" : "Password removed successfully"
});
}
catch (err) {
console.error("Change Password Error:", err);
return reply.code(500).send({ error: "Failed to change password" });
}
}

View File

@@ -0,0 +1,49 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const controller = __importStar(require("./user.controller"));
async function userRoutes(fastify) {
fastify.get('/me', controller.getMe);
fastify.post("/login", controller.login);
fastify.get('/users', controller.getAllUsers);
fastify.post('/users', { bodyLimit: 1024 * 1024 * 50 }, controller.createUser);
fastify.get('/users/:id', controller.getUser);
fastify.put('/users/:id', { bodyLimit: 1024 * 1024 * 50 }, controller.updateUser);
fastify.delete('/users/:id', controller.deleteUser);
fastify.get('/users/:id/integration', controller.getIntegrationStatus);
fastify.delete('/users/:id/integration', controller.disconnectAniList);
fastify.put('/users/:id/password', controller.changePassword);
}
exports.default = userRoutes;

View File

@@ -0,0 +1,144 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.userExists = userExists;
exports.createUser = createUser;
exports.updateUser = updateUser;
exports.deleteUser = deleteUser;
exports.getAllUsers = getAllUsers;
exports.getUserById = getUserById;
exports.verifyPassword = verifyPassword;
exports.getAniListIntegration = getAniListIntegration;
exports.removeAniListIntegration = removeAniListIntegration;
const database_1 = require("../../shared/database");
const bcrypt_1 = __importDefault(require("bcrypt"));
const USER_DB_NAME = 'userdata';
const SALT_ROUNDS = 10;
async function userExists(id) {
const sql = 'SELECT 1 FROM User WHERE id = ?';
const row = await (0, database_1.queryOne)(sql, [id], USER_DB_NAME);
return !!row;
}
async function createUser(username, profilePictureUrl, password) {
let passwordHash = null;
if (password && password.trim()) {
passwordHash = await bcrypt_1.default.hash(password.trim(), SALT_ROUNDS);
}
const sql = `
INSERT INTO User (username, profile_picture_url, password_hash)
VALUES (?, ?, ?)
`;
const params = [username, profilePictureUrl || null, passwordHash];
const result = await (0, database_1.run)(sql, params, USER_DB_NAME);
return { lastID: result.lastID };
}
async function updateUser(userId, updates) {
const fields = [];
const values = [];
if (updates.username !== undefined) {
fields.push('username = ?');
values.push(updates.username);
}
if (updates.profilePictureUrl !== undefined) {
fields.push('profile_picture_url = ?');
values.push(updates.profilePictureUrl);
}
if (updates.password !== undefined) {
if (updates.password === null || updates.password === '') {
// Eliminar contraseña
fields.push('password_hash = ?');
values.push(null);
}
else {
// Actualizar contraseña
const hash = await bcrypt_1.default.hash(updates.password.trim(), SALT_ROUNDS);
fields.push('password_hash = ?');
values.push(hash);
}
}
if (fields.length === 0) {
return { changes: 0, lastID: userId };
}
const setClause = fields.join(', ');
const sql = `UPDATE User SET ${setClause} WHERE id = ?`;
values.push(userId);
return await (0, database_1.run)(sql, values, USER_DB_NAME);
}
async function deleteUser(userId) {
await (0, database_1.run)(`DELETE FROM ListEntry WHERE user_id = ?`, [userId], USER_DB_NAME);
await (0, database_1.run)(`DELETE FROM UserIntegration WHERE user_id = ?`, [userId], USER_DB_NAME);
await (0, database_1.run)(`DELETE FROM favorites WHERE user_id = ?`, [userId], 'favorites');
const result = await (0, database_1.run)(`DELETE FROM User WHERE id = ?`, [userId], USER_DB_NAME);
return result;
}
async function getAllUsers() {
const sql = `
SELECT
id,
username,
profile_picture_url,
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
FROM User
ORDER BY id
`;
const users = await (0, database_1.queryAll)(sql, [], USER_DB_NAME);
return users.map((user) => ({
id: user.id,
username: user.username,
profile_picture_url: user.profile_picture_url || null,
has_password: !!user.has_password
}));
}
async function getUserById(id) {
const sql = `
SELECT
id,
username,
profile_picture_url,
CASE WHEN password_hash IS NOT NULL THEN 1 ELSE 0 END as has_password
FROM User
WHERE id = ?
`;
const user = await (0, database_1.queryOne)(sql, [id], USER_DB_NAME);
if (!user)
return null;
return {
id: user.id,
username: user.username,
profile_picture_url: user.profile_picture_url || null,
has_password: !!user.has_password
};
}
async function verifyPassword(userId, password) {
const sql = 'SELECT password_hash FROM User WHERE id = ?';
const user = await (0, database_1.queryOne)(sql, [userId], USER_DB_NAME);
if (!user || !user.password_hash) {
return false;
}
return await bcrypt_1.default.compare(password, user.password_hash);
}
async function getAniListIntegration(userId) {
const sql = `
SELECT anilist_user_id, expires_at
FROM UserIntegration
WHERE user_id = ? AND platform = ?
`;
const row = await (0, database_1.queryOne)(sql, [userId, "AniList"], USER_DB_NAME);
if (!row) {
return { connected: false };
}
return {
connected: true,
anilistUserId: row.anilist_user_id,
expiresAt: row.expires_at
};
}
async function removeAniListIntegration(userId) {
const sql = `
DELETE FROM UserIntegration
WHERE user_id = ? AND platform = ?
`;
return (0, database_1.run)(sql, [userId, "AniList"], USER_DB_NAME);
}