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