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.
This commit is contained in:
564
desktop/src/api/anilist/anilist.service.ts
Normal file
564
desktop/src/api/anilist/anilist.service.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user