Files
WaifuBoard/desktop/src/api/anilist/anilist.service.ts
itsskaiya 28ff6ccc68 Organized the differences between server and docker versions.
We are launching a docker version (server version) today so we want to just organize the repo
so its easier to navigate.
2025-12-16 21:50:22 -05:00

565 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { queryOne } from '../../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: string,
options: RequestInit,
retries = RETRY_CONFIG.maxRetries
): Promise<Response> {
let lastError: Error | null = 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 as 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');
}
export async function getUserAniList(appUserId: number) {
try {
const sql = `
SELECT access_token, anilist_user_id
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
const integration = await queryOne(sql, [appUserId], USER_DB) as any;
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: any) => {
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: any[], type: 'ANIME' | 'MANGA') => {
const result: any[] = [];
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 [];
}
}
export async function updateAniListEntry(token: string, params: {
mediaId: number | string;
status?: string | null;
progress?: number | null;
score?: number | null;
start_date?: string | null; // YYYY-MM-DD
end_date?: string | null; // YYYY-MM-DD
repeat_count?: number | null;
notes?: string | null;
is_private?: boolean | number | null;
}) {
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?: string | null) => {
if (!dateStr) return null;
const [year, month, day] = dateStr.split('-').map(Number);
return { year, month, day };
};
const variables: any = {
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;
}
}
export async function deleteAniListEntry(token: string, mediaId: number) {
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;
}
}
export async function getSingleAniListEntry(
token: string,
mediaId: number,
type: 'ANIME' | 'MANGA'
) {
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;
}
}