Fixed bundling into an exe not working as intended.
This commit is contained in:
76
electron/api/anilist.js
Normal file
76
electron/api/anilist.js
Normal 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;
|
||||
76
electron/api/anilist/anilist.js
Normal file
76
electron/api/anilist/anilist.js
Normal 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;
|
||||
478
electron/api/anilist/anilist.service.js
Normal file
478
electron/api/anilist/anilist.service.js
Normal 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;
|
||||
}
|
||||
}
|
||||
130
electron/api/anime/anime.controller.js
Normal file
130
electron/api/anime/anime.controller.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
46
electron/api/anime/anime.routes.js
Normal file
46
electron/api/anime/anime.routes.js
Normal 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;
|
||||
375
electron/api/anime/anime.service.js
Normal file
375
electron/api/anime/anime.service.js
Normal 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;
|
||||
}
|
||||
140
electron/api/books/books.controller.js
Normal file
140
electron/api/books/books.controller.js
Normal 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" });
|
||||
}
|
||||
}
|
||||
46
electron/api/books/books.routes.js
Normal file
46
electron/api/books/books.routes.js
Normal 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;
|
||||
482
electron/api/books/books.service.js
Normal file
482
electron/api/books/books.service.js
Normal 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;
|
||||
}
|
||||
}
|
||||
71
electron/api/extensions/extensions.controller.js
Normal file
71
electron/api/extensions/extensions.controller.js
Normal 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}.` });
|
||||
}
|
||||
}
|
||||
46
electron/api/extensions/extensions.routes.js
Normal file
46
electron/api/extensions/extensions.routes.js
Normal 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;
|
||||
172
electron/api/gallery/gallery.controller.js
Normal file
172
electron/api/gallery/gallery.controller.js
Normal 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" });
|
||||
}
|
||||
}
|
||||
46
electron/api/gallery/gallery.routes.js
Normal file
46
electron/api/gallery/gallery.routes.js
Normal 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;
|
||||
176
electron/api/gallery/gallery.service.js
Normal file
176
electron/api/gallery/gallery.service.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
158
electron/api/list/list.controller.js
Normal file
158
electron/api/list/list.controller.js
Normal 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" });
|
||||
}
|
||||
}
|
||||
44
electron/api/list/list.routes.js
Normal file
44
electron/api/list/list.routes.js
Normal 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;
|
||||
491
electron/api/list/list.service.js
Normal file
491
electron/api/list/list.service.js
Normal 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.");
|
||||
}
|
||||
}
|
||||
48
electron/api/proxy/proxy.controller.js
Normal file
48
electron/api/proxy/proxy.controller.js
Normal 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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
7
electron/api/proxy/proxy.routes.js
Normal file
7
electron/api/proxy/proxy.routes.js
Normal 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;
|
||||
111
electron/api/proxy/proxy.service.js
Normal file
111
electron/api/proxy/proxy.service.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
102
electron/api/rpc/rp.service.js
Normal file
102
electron/api/rpc/rp.service.js
Normal 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);
|
||||
}
|
||||
}
|
||||
21
electron/api/rpc/rpc.controller.js
Normal file
21
electron/api/rpc/rpc.controller.js
Normal 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 });
|
||||
}
|
||||
40
electron/api/rpc/rpc.routes.js
Normal file
40
electron/api/rpc/rpc.routes.js
Normal 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
2
electron/api/types.js
Normal file
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
247
electron/api/user/user.controller.js
Normal file
247
electron/api/user/user.controller.js
Normal 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" });
|
||||
}
|
||||
}
|
||||
49
electron/api/user/user.routes.js
Normal file
49
electron/api/user/user.routes.js
Normal 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;
|
||||
144
electron/api/user/user.service.js
Normal file
144
electron/api/user/user.service.js
Normal 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);
|
||||
}
|
||||
115
electron/shared/database.js
Normal file
115
electron/shared/database.js
Normal file
@@ -0,0 +1,115 @@
|
||||
"use strict";
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const { ensureUserDataDB, ensureAnilistSchema, ensureExtensionsTable, ensureCacheTable, ensureFavoritesDB } = require('./schemas');
|
||||
const databases = new Map();
|
||||
const DEFAULT_PATHS = {
|
||||
anilist: path.join(os.homedir(), "WaifuBoards", 'anilist_anime.db'),
|
||||
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"),
|
||||
cache: path.join(os.homedir(), "WaifuBoards", "cache.db"),
|
||||
userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db")
|
||||
};
|
||||
function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
|
||||
if (databases.has(name)) {
|
||||
return databases.get(name);
|
||||
}
|
||||
const finalPath = dbPath || DEFAULT_PATHS[name] || DEFAULT_PATHS.anilist;
|
||||
if (name === "favorites") {
|
||||
ensureFavoritesDB(finalPath)
|
||||
.catch(err => console.error("Error creando favorites:", err));
|
||||
}
|
||||
if (name === "cache") {
|
||||
const dir = path.dirname(finalPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
if (name === "userdata") {
|
||||
ensureUserDataDB(finalPath)
|
||||
.catch(err => console.error("Error creando userdata:", err));
|
||||
}
|
||||
const mode = readOnly ? sqlite3.OPEN_READONLY : (sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE);
|
||||
const db = new sqlite3.Database(finalPath, mode, (err) => {
|
||||
if (err) {
|
||||
console.error(`Database Error (${name}):`, err.message);
|
||||
}
|
||||
else {
|
||||
console.log(`Connected to ${name} database at ${finalPath}`);
|
||||
}
|
||||
});
|
||||
databases.set(name, db);
|
||||
if (name === "anilist") {
|
||||
ensureAnilistSchema(db)
|
||||
.then(() => ensureExtensionsTable(db))
|
||||
.catch(err => console.error("Error creating anilist schema:", err));
|
||||
}
|
||||
if (name === "cache") {
|
||||
ensureCacheTable(db)
|
||||
.catch(err => console.error("Error creating cache table:", err));
|
||||
}
|
||||
return db;
|
||||
}
|
||||
function getDatabase(name = 'anilist') {
|
||||
if (!databases.has(name)) {
|
||||
const readOnly = (name === 'anilist');
|
||||
return initDatabase(name, null, readOnly);
|
||||
}
|
||||
return databases.get(name);
|
||||
}
|
||||
function queryOne(sql, params = [], dbName = 'anilist') {
|
||||
return new Promise((resolve, reject) => {
|
||||
getDatabase(dbName).get(sql, params, (err, row) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
function queryAll(sql, params = [], dbName = 'anilist') {
|
||||
return new Promise((resolve, reject) => {
|
||||
getDatabase(dbName).all(sql, params, (err, rows) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
resolve(rows || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
function run(sql, params = [], dbName = 'anilist') {
|
||||
return new Promise((resolve, reject) => {
|
||||
getDatabase(dbName).run(sql, params, function (err) {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
resolve({ changes: this.changes, lastID: this.lastID });
|
||||
});
|
||||
});
|
||||
}
|
||||
function closeDatabase(name = null) {
|
||||
if (name) {
|
||||
const db = databases.get(name);
|
||||
if (db) {
|
||||
db.close();
|
||||
databases.delete(name);
|
||||
console.log(`Closed ${name} database`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
for (const [dbName, db] of databases) {
|
||||
db.close();
|
||||
console.log(`Closed ${dbName} database`);
|
||||
}
|
||||
databases.clear();
|
||||
}
|
||||
}
|
||||
module.exports = {
|
||||
initDatabase,
|
||||
getDatabase,
|
||||
queryOne,
|
||||
queryAll,
|
||||
run,
|
||||
closeDatabase
|
||||
};
|
||||
174
electron/shared/extensions.js
Normal file
174
electron/shared/extensions.js
Normal file
@@ -0,0 +1,174 @@
|
||||
"use strict";
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { queryAll, run } = require('./database');
|
||||
const { scrape } = require("./headless");
|
||||
const extensions = new Map();
|
||||
async function loadExtensions() {
|
||||
const homeDir = os.homedir();
|
||||
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
|
||||
if (!fs.existsSync(extensionsDir)) {
|
||||
console.log("📁 Extensions directory not found, creating...");
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
}
|
||||
try {
|
||||
const files = await fs.promises.readdir(extensionsDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.js')) {
|
||||
await loadExtension(file);
|
||||
}
|
||||
}
|
||||
console.log(`✅ Loaded ${extensions.size} extensions`);
|
||||
try {
|
||||
const loaded = Array.from(extensions.keys());
|
||||
const rows = await queryAll("SELECT DISTINCT ext_name FROM extension");
|
||||
for (const row of rows) {
|
||||
if (!loaded.includes(row.ext_name)) {
|
||||
console.log(`🧹 Cleaning cached metadata for removed extension: ${row.ext_name}`);
|
||||
await run("DELETE FROM extension WHERE ext_name = ?", [row.ext_name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("❌ Error cleaning extension cache:", err);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("❌ Extension Scan Error:", err);
|
||||
}
|
||||
}
|
||||
async function loadExtension(fileName) {
|
||||
const homeDir = os.homedir();
|
||||
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
|
||||
const filePath = path.join(extensionsDir, fileName);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(`⚠️ Extension not found: ${fileName}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
delete require.cache[require.resolve(filePath)];
|
||||
const ExtensionClass = require(filePath);
|
||||
const instance = typeof ExtensionClass === 'function'
|
||||
? new ExtensionClass()
|
||||
: (ExtensionClass.default ? new ExtensionClass.default() : null);
|
||||
if (!instance) {
|
||||
console.warn(`⚠️ Invalid extension: ${fileName}`);
|
||||
return;
|
||||
}
|
||||
if (!["anime-board", "book-board", "image-board"].includes(instance.type)) {
|
||||
console.warn(`⚠️ Invalid extension (${instance.type}): ${fileName}`);
|
||||
return;
|
||||
}
|
||||
const name = instance.constructor.name;
|
||||
instance.scrape = scrape;
|
||||
extensions.set(name, instance);
|
||||
console.log(`📦 Installed & loaded: ${name}`);
|
||||
return name;
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`⚠️ Error loading ${fileName}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
const https = require('https');
|
||||
async function saveExtensionFile(fileName, downloadUrl) {
|
||||
const homeDir = os.homedir();
|
||||
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
|
||||
const filePath = path.join(extensionsDir, fileName);
|
||||
const fullUrl = downloadUrl;
|
||||
if (!fs.existsSync(extensionsDir)) {
|
||||
fs.mkdirSync(extensionsDir, { recursive: true });
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(filePath);
|
||||
https.get(fullUrl, async (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
return reject(new Error(`Download failed: ${response.statusCode}`));
|
||||
}
|
||||
response.pipe(file);
|
||||
file.on('finish', async () => {
|
||||
file.close(async () => {
|
||||
try {
|
||||
await loadExtension(fileName);
|
||||
resolve();
|
||||
}
|
||||
catch (err) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
reject(new Error(`Load failed, file rolled back: ${err.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}).on('error', async (err) => {
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.promises.unlink(filePath);
|
||||
}
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
async function deleteExtensionFile(fileName) {
|
||||
const homeDir = os.homedir();
|
||||
const extensionsDir = path.join(homeDir, 'WaifuBoards', 'extensions');
|
||||
const filePath = path.join(extensionsDir, fileName);
|
||||
const extName = fileName.replace(".js", "");
|
||||
for (const key of extensions.keys()) {
|
||||
if (key.toLowerCase() === extName) {
|
||||
extensions.delete(key);
|
||||
console.log(`🗑️ Removed from memory: ${key}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(filePath)) {
|
||||
await fs.promises.unlink(filePath);
|
||||
console.log(`🗑️ Deleted file: ${fileName}`);
|
||||
}
|
||||
}
|
||||
function getExtension(name) {
|
||||
return extensions.get(name);
|
||||
}
|
||||
function getAllExtensions() {
|
||||
return extensions;
|
||||
}
|
||||
function getExtensionsList() {
|
||||
return Array.from(extensions.keys());
|
||||
}
|
||||
function getAnimeExtensionsMap() {
|
||||
const animeExts = new Map();
|
||||
for (const [name, ext] of extensions) {
|
||||
if (ext.type === 'anime-board') {
|
||||
animeExts.set(name, ext);
|
||||
}
|
||||
}
|
||||
return animeExts;
|
||||
}
|
||||
function getBookExtensionsMap() {
|
||||
const bookExts = new Map();
|
||||
for (const [name, ext] of extensions) {
|
||||
if (ext.type === 'book-board' || ext.type === 'manga-board') {
|
||||
bookExts.set(name, ext);
|
||||
}
|
||||
}
|
||||
return bookExts;
|
||||
}
|
||||
function getGalleryExtensionsMap() {
|
||||
const galleryExts = new Map();
|
||||
for (const [name, ext] of extensions) {
|
||||
if (ext.type === 'image-board') {
|
||||
galleryExts.set(name, ext);
|
||||
}
|
||||
}
|
||||
return galleryExts;
|
||||
}
|
||||
module.exports = {
|
||||
loadExtensions,
|
||||
getExtension,
|
||||
getAllExtensions,
|
||||
getExtensionsList,
|
||||
getAnimeExtensionsMap,
|
||||
getBookExtensionsMap,
|
||||
getGalleryExtensionsMap,
|
||||
saveExtensionFile,
|
||||
deleteExtensionFile
|
||||
};
|
||||
108
electron/shared/headless.js
Normal file
108
electron/shared/headless.js
Normal file
@@ -0,0 +1,108 @@
|
||||
"use strict";
|
||||
const { chromium } = require("playwright-chromium");
|
||||
let browser;
|
||||
let context;
|
||||
const BLOCK_LIST = [
|
||||
"google-analytics", "doubleclick", "facebook", "twitter",
|
||||
"adsystem", "analytics", "tracker", "pixel", "quantserve", "newrelic"
|
||||
];
|
||||
async function initHeadless() {
|
||||
if (browser)
|
||||
return;
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-sync",
|
||||
"--disable-translate",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
"--no-zygote",
|
||||
"--single-process"
|
||||
]
|
||||
});
|
||||
context = await browser.newContext({
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36"
|
||||
});
|
||||
}
|
||||
async function turboScroll(page) {
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
let last = 0;
|
||||
let same = 0;
|
||||
const timer = setInterval(() => {
|
||||
const h = document.body.scrollHeight;
|
||||
window.scrollTo(0, h);
|
||||
if (h === last) {
|
||||
same++;
|
||||
if (same >= 5) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
else {
|
||||
same = 0;
|
||||
last = h;
|
||||
}
|
||||
}, 20);
|
||||
});
|
||||
});
|
||||
}
|
||||
async function scrape(url, handler, options = {}) {
|
||||
const { waitUntil = "domcontentloaded", waitSelector = null, timeout = 10000, scrollToBottom = false, renderWaitTime = 0, loadImages = true } = options;
|
||||
if (!browser)
|
||||
await initHeadless();
|
||||
const page = await context.newPage();
|
||||
let collectedRequests = [];
|
||||
await page.route("**/*", (route) => {
|
||||
const req = route.request();
|
||||
const rUrl = req.url().toLowerCase();
|
||||
const type = req.resourceType();
|
||||
collectedRequests.push({
|
||||
url: req.url(),
|
||||
method: req.method(),
|
||||
resourceType: type
|
||||
});
|
||||
if (type === "font" || type === "media" || type === "manifest")
|
||||
return route.abort();
|
||||
if (BLOCK_LIST.some(k => rUrl.includes(k)))
|
||||
return route.abort();
|
||||
if (!loadImages && (type === "image" || rUrl.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)))
|
||||
return route.abort();
|
||||
route.continue();
|
||||
});
|
||||
await page.goto(url, { waitUntil, timeout });
|
||||
if (waitSelector) {
|
||||
try {
|
||||
await page.waitForSelector(waitSelector, { timeout });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
if (scrollToBottom) {
|
||||
await turboScroll(page);
|
||||
}
|
||||
if (renderWaitTime > 0) {
|
||||
await new Promise(r => setTimeout(r, renderWaitTime));
|
||||
}
|
||||
const result = await handler(page);
|
||||
await page.close();
|
||||
return { result, requests: collectedRequests };
|
||||
}
|
||||
async function closeScraper() {
|
||||
if (context)
|
||||
await context.close();
|
||||
if (browser)
|
||||
await browser.close();
|
||||
context = null;
|
||||
browser = null;
|
||||
}
|
||||
module.exports = {
|
||||
initHeadless,
|
||||
scrape,
|
||||
closeScraper
|
||||
};
|
||||
43
electron/shared/queries.js
Normal file
43
electron/shared/queries.js
Normal file
@@ -0,0 +1,43 @@
|
||||
"use strict";
|
||||
const { queryOne, run } = require('./database');
|
||||
async function getCachedExtension(extName, id) {
|
||||
return queryOne("SELECT metadata FROM extension WHERE ext_name = ? AND id = ?", [extName, id]);
|
||||
}
|
||||
async function cacheExtension(extName, id, title, metadata) {
|
||||
return run(`
|
||||
INSERT INTO extension (ext_name, id, title, metadata, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(ext_name, id)
|
||||
DO UPDATE SET
|
||||
title = excluded.title,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = ?
|
||||
`, [extName, id, title, JSON.stringify(metadata), Date.now(), Date.now()]);
|
||||
}
|
||||
async function getExtensionTitle(extName, id) {
|
||||
const sql = "SELECT title FROM extension WHERE ext_name = ? AND id = ?";
|
||||
const row = await queryOne(sql, [extName, id], 'anilist');
|
||||
return row ? row.title : null;
|
||||
}
|
||||
async function deleteExtension(extName) {
|
||||
return run("DELETE FROM extension WHERE ext_name = ?", [extName]);
|
||||
}
|
||||
async function getCache(key) {
|
||||
return queryOne("SELECT result, created_at, ttl_ms FROM cache WHERE key = ?", [key], "cache");
|
||||
}
|
||||
async function setCache(key, result, ttl_ms) {
|
||||
return run(`
|
||||
INSERT INTO cache (key, result, created_at, ttl_ms)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key)
|
||||
DO UPDATE SET result = excluded.result, created_at = excluded.created_at, ttl_ms = excluded.ttl_ms
|
||||
`, [key, JSON.stringify(result), Date.now(), ttl_ms], "cache");
|
||||
}
|
||||
module.exports = {
|
||||
getCachedExtension,
|
||||
cacheExtension,
|
||||
getExtensionTitle,
|
||||
deleteExtension,
|
||||
getCache,
|
||||
setCache
|
||||
};
|
||||
217
electron/shared/schemas.js
Normal file
217
electron/shared/schemas.js
Normal file
@@ -0,0 +1,217 @@
|
||||
"use strict";
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
async function ensureUserDataDB(dbPath) {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE);
|
||||
return new Promise((resolve, reject) => {
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS User (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
profile_picture_url TEXT,
|
||||
email TEXT UNIQUE,
|
||||
password_hash TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS UserIntegration (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE,
|
||||
platform TEXT NOT NULL DEFAULT 'AniList',
|
||||
access_token TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL,
|
||||
token_type TEXT NOT NULL,
|
||||
anilist_user_id INTEGER NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ListEntry (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
entry_id INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
entry_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER NOT NULL DEFAULT 0,
|
||||
score INTEGER,
|
||||
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
repeat_count INTEGER NOT NULL DEFAULT 0,
|
||||
notes TEXT,
|
||||
is_private BOOLEAN NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, entry_id),
|
||||
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||
);
|
||||
`;
|
||||
db.exec(schema, (err) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
async function ensureAnilistSchema(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS anime (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
updatedAt INTEGER,
|
||||
cache_created_at INTEGER DEFAULT 0,
|
||||
cache_ttl_ms INTEGER DEFAULT 0,
|
||||
full_data JSON
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trending (
|
||||
rank INTEGER PRIMARY KEY,
|
||||
id INTEGER,
|
||||
full_data JSON,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS top_airing (
|
||||
rank INTEGER PRIMARY KEY,
|
||||
id INTEGER,
|
||||
full_data JSON,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS books (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT,
|
||||
updatedAt INTEGER,
|
||||
full_data JSON
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trending_books (
|
||||
rank INTEGER PRIMARY KEY,
|
||||
id INTEGER,
|
||||
full_data JSON,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS popular_books (
|
||||
rank INTEGER PRIMARY KEY,
|
||||
id INTEGER,
|
||||
full_data JSON,
|
||||
updated_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`;
|
||||
db.exec(schema, (err) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
async function ensureExtensionsTable(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS extension (
|
||||
ext_name TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
metadata TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(ext_name, id)
|
||||
);
|
||||
`, (err) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
async function ensureCacheTable(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
key TEXT PRIMARY KEY,
|
||||
result TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
ttl_ms INTEGER NOT NULL
|
||||
);
|
||||
`, (err) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
function ensureFavoritesDB(dbPath) {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const exists = fs.existsSync(dbPath);
|
||||
const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE);
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!exists) {
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
image_url TEXT NOT NULL,
|
||||
thumbnail_url TEXT NOT NULL DEFAULT "",
|
||||
tags TEXT NOT NULL DEFAULT "",
|
||||
headers TEXT NOT NULL DEFAULT "",
|
||||
provider TEXT NOT NULL DEFAULT "",
|
||||
PRIMARY KEY (id, user_id)
|
||||
);
|
||||
`;
|
||||
db.exec(schema, (err) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
resolve(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
db.all(`PRAGMA table_info(favorites)`, (err, cols) => {
|
||||
if (err)
|
||||
return reject(err);
|
||||
const hasHeaders = cols.some(c => c.name === "headers");
|
||||
const hasProvider = cols.some(c => c.name === "provider");
|
||||
const hasUserId = cols.some(c => c.name === "user_id");
|
||||
const queries = [];
|
||||
if (!hasHeaders) {
|
||||
queries.push(`ALTER TABLE favorites ADD COLUMN headers TEXT NOT NULL DEFAULT ""`);
|
||||
}
|
||||
if (!hasProvider) {
|
||||
queries.push(`ALTER TABLE favorites ADD COLUMN provider TEXT NOT NULL DEFAULT ""`);
|
||||
}
|
||||
if (!hasUserId) {
|
||||
queries.push(`ALTER TABLE favorites ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1`);
|
||||
}
|
||||
if (queries.length === 0) {
|
||||
return resolve(false);
|
||||
}
|
||||
db.exec(queries.join(";"), (err) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
module.exports = {
|
||||
ensureUserDataDB,
|
||||
ensureAnilistSchema,
|
||||
ensureExtensionsTable,
|
||||
ensureCacheTable,
|
||||
ensureFavoritesDB
|
||||
};
|
||||
100
electron/views/views.routes.js
Normal file
100
electron/views/views.routes.js
Normal file
@@ -0,0 +1,100 @@
|
||||
"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 fs = __importStar(require("fs"));
|
||||
const path = __importStar(require("path"));
|
||||
async function viewsRoutes(fastify) {
|
||||
fastify.get('/', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'users.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/anime', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'animes.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/my-list', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'list.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/books', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'books.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/schedule', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'schedule.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/gallery', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'gallery.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/marketplace', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'marketplace.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/gallery/:extension/*', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/gallery/favorites/*', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery', 'image.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/anime/:id', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/anime/:extension/*', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'anime.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/watch/:id/:episode', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'watch.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/book/:id', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'book.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/book/:extension/*', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'book.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
fastify.get('/read/:provider/:chapter/*', (req, reply) => {
|
||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'books', 'read.html'));
|
||||
reply.type('text/html').send(stream);
|
||||
});
|
||||
}
|
||||
exports.default = viewsRoutes;
|
||||
Reference in New Issue
Block a user