import {queryAll, run, queryOne} from '../../shared/database'; import {getExtension} from '../../shared/extensions'; import * as animeService from '../anime/anime.service'; import * as booksService from '../books/books.service'; import * as aniListService from '../anilist/anilist.service'; interface ListEntryData { entry_type: any; user_id: number; entry_id: number; source: string; status: string; progress: number; score: number | null; } const USER_DB = 'userdata'; export async function upsertListEntry(entry: any) { const { user_id, entry_id, source, entry_type, status, progress, score, start_date, end_date, repeat_count, notes, is_private } = entry; let prev: any = null; try { prev = await getSingleListEntry(user_id, entry_id, source, entry_type); } catch { prev = null; // ✅ si AniList da 404 u otro error → se trata como nuevo } const isNew = !prev; // ✅ NO permitir retroceso SOLO si ya existía if (!isNew && prev?.progress != null && progress < prev.progress) { return { changes: 0, ignored: true }; } const today = new Date().toISOString().slice(0, 10); // ✅ NUNCA borrar start_date si ya existía if (prev?.start_date && !entry.start_date) { entry.start_date = prev.start_date; } // ✅ START DATE solo al comenzar de verdad if (!prev?.start_date && progress === 1) { entry.start_date = today; } // ✅ TOTAL solo si existe const total = prev?.total_episodes ?? prev?.total_chapters ?? null; // ✅ COMPLETED automático 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 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."); } } export async function getUserList(userId: number): Promise { const sql = ` SELECT * FROM ListEntry WHERE user_id = ? ORDER BY updated_at DESC; `; try { const dbList = await queryAll(sql, [userId], USER_DB) as ListEntryData[]; const connected = await isConnected(userId); let finalList: any[] = [...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) => { // ✅ Si viene de AniList, ya está completo → NO fetch 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', }; } // ✅ Solo se hace fetch para fuentes NO AniList let contentDetails: any | null = null; const id = entry.entry_id; const type = entry.entry_type; const ext = getExtension(entry.source); try { if (type === 'ANIME') { const anime: any = 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:any = 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 en la base de datos al obtener la lista."); } } export async function deleteListEntry( userId: number, entryId: string | number, source: string ) { 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); } } } // ✅ SOLO LOCAL const sql = ` DELETE FROM ListEntry WHERE user_id = ? AND entry_id = ?; `; const result = await run(sql, [userId, entryId], USER_DB); return { success: result.changes > 0, changes: result.changes, external: false }; } export async function getSingleListEntry( userId: number, entryId: string | number, source: string, entryType: string ): Promise { // ✅ 1. BUSCAR PRIMERO EN TU BASE DE DATOS const localSql = ` SELECT * FROM ListEntry WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?; `; const localResult = await queryAll( localSql, [userId, entryId, source, entryType], USER_DB ) as any[]; if (localResult.length > 0) { const entry = localResult[0]; const contentDetails: any = 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 queryOne(sql, [userId], USER_DB) as any; if (!integration?.access_token) return null; const aniEntry = await aniListService.getSingleAniListEntry( integration.access_token, Number(entryId), entryType as any ); if (!aniEntry) return null; const contentDetails: any = 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; } export async function getActiveAccessToken(userId: number): Promise { const sql = ` SELECT access_token, expires_at FROM UserIntegration WHERE user_id = ? AND platform = 'AniList'; `; try { const integration = await queryOne(sql, [userId], USER_DB) as any | null; 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; } } export async function isConnected(userId: number): Promise { const token = await getActiveAccessToken(userId); return !!token; } export async function getUserListByFilter( userId: number, status?: string, entryType?: string ): Promise { let sql = ` SELECT * FROM ListEntry WHERE user_id = ? ORDER BY updated_at DESC; `; const params: any[] = [userId]; try { const dbList = await queryAll(sql, params, USER_DB) as ListEntryData[]; const connected = await isConnected(userId); const statusMap: any = { watching: 'CURRENT', reading: 'CURRENT', completed: 'COMPLETED', paused: 'PAUSED', dropped: 'DROPPED', planning: 'PLANNING' }; const mappedStatus = status ? statusMap[status.toLowerCase()] : null; let finalList: any[] = []; // ✅ FILTRADO LOCAL (MANGA + NOVEL) const filteredLocal = dbList.filter((entry) => { if (mappedStatus && entry.status !== mappedStatus) return false; if (entryType) { if (entryType === 'MANGA') { // ✅ AHORA ACEPTA MANGA Y NOVEL if (!['MANGA', 'NOVEL'].includes(entry.entry_type)) return false; } else { if (entry.entry_type !== entryType) return false; } } return true; }); // ✅ FILTRADO ANILIST (MANGA + NOVEL TAMBIÉN) let filteredAniList: any[] = []; if (connected) { const anilistEntries = await aniListService.getUserAniList(userId); filteredAniList = anilistEntries.filter((entry: any) => { 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) => { // ✅ AniList directo 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', }; } // ✅ LOCAL → FETCH EXTERNO let contentDetails: any | null = null; const id = entry.entry_id; const type = entry.entry_type; const ext = getExtension(entry.source); try { if (type === 'ANIME') { const anime: any = 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: any = 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."); } }