diff --git a/src/api/anilist/anilist.service.ts b/src/api/anilist/anilist.service.ts index 48940aa..7d8e837 100644 --- a/src/api/anilist/anilist.service.ts +++ b/src/api/anilist/anilist.service.ts @@ -2,81 +2,192 @@ 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) { - const sql = ` - SELECT access_token, anilist_user_id - FROM UserIntegration - WHERE user_id = ? AND platform = 'AniList'; - `; + 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 integration = await queryOne(sql, [appUserId], USER_DB) as any; + if (!integration) return []; - const { access_token, anilist_user_id } = integration; + 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 { - mediaId - status - progress - score + const query = ` + query ($userId: Int) { + anime: MediaListCollection(userId: $userId, type: ANIME) { + lists { + entries { + mediaId + status + progress + score + startedAt { year month day } + completedAt { year month day } + repeat + notes + private + } + } + } + manga: MediaListCollection(userId: $userId, type: MANGA) { + lists { + entries { + mediaId + status + progress + score + startedAt { year month day } + completedAt { year month day } + repeat + notes + private + } } } } - manga: MediaListCollection(userId: $userId, type: MANGA) { - lists { - entries { - mediaId - status - progress - score - } + `; + + 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 || []) { + result.push({ + user_id: appUserId, + entry_id: entry.mediaId, + source: 'anilist', + entry_type: type, + 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 + }); } } - } - `; - const res = await fetch('https://graphql.anilist.co', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${access_token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query, - variables: { userId: anilist_user_id } - }), - }); + return result; + }; - const json = await res.json(); - - const normalize = (lists: any[], type: 'ANIME' | 'MANGA') => { - const result: any[] = []; - - for (const list of lists || []) { - for (const entry of list.entries || []) { - result.push({ - user_id: appUserId, - entry_id: entry.mediaId, - source: 'anilist', - entry_type: type, - status: entry.status, - progress: entry.progress || 0, - score: entry.score || null, - }); - } - } - - return result; - }; - - return [ - ...normalize(json?.data?.anime?.lists, 'ANIME'), - ...normalize(json?.data?.manga?.lists, 'MANGA') - ]; + 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: { @@ -84,131 +195,318 @@ export async function updateAniListEntry(token: string, params: { 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; }) { - const mutation = ` - mutation ($mediaId: Int, $status: MediaListStatus, $progress: Int, $score: Float) { - SaveMediaListEntry ( - mediaId: $mediaId, - status: $status, - progress: $progress, - score: $score + 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 ) { - id - status - progress - score + 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 variables: any = { - mediaId: Number(params.mediaId), - }; + const json = await res.json(); - if (params.status != null) variables.status = params.status; - if (params.progress != null) variables.progress = params.progress; - if (params.score != null) variables.score = params.score; + if (json?.errors?.length) { + throw new Error(`AniList GraphQL error: ${json.errors[0].message}`); + } - const res = await fetch('https://graphql.anilist.co', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query: mutation, variables }), - }); - - const json = await res.json(); - - if (!res.ok || json?.errors?.length) { - throw new Error("AniList update failed"); + return json.data?.SaveMediaListEntry || null; + } catch (error) { + console.error('Error updating AniList entry:', error); + throw error; } - - return json.data?.SaveMediaListEntry || null; } export async function deleteAniListEntry(token: string, mediaId: number) { + if (!token) throw new Error("AniList token required"); - const query = ` - query ($mediaId: Int) { - MediaList(mediaId: $mediaId) { - id - } + 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 qRes = await fetch('https://graphql.anilist.co', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query, variables: { mediaId } }), - }); + 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 qJson = await qRes.json(); - const listEntryId = qJson?.data?.MediaList?.id; + const mTypeJson = await mTypeRes.json(); + const mediaType = mTypeJson?.data?.Media?.type; - if (!listEntryId) { - throw new Error("Entry not found or unauthorized to delete."); - } - - const mutation = ` - mutation ($id: Int) { - DeleteMediaListEntry(id: $id) { - deleted - } + 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 mRes = await fetch('https://graphql.anilist.co', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: mutation, - variables: { id: listEntryId } - }), - }); + 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 mJson = await mRes.json(); + const qJson = await qRes.json(); + const listEntryId = qJson?.data?.MediaList?.id; - if (mJson?.errors?.length) { - throw new Error("Error eliminando entrada en AniList"); + 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; } - - return true; } + export async function getSingleAniListEntry( token: string, mediaId: number, type: 'ANIME' | 'MANGA' ) { - const query = ` - query ($mediaId: Int, $type: MediaType) { - MediaList(mediaId: $mediaId, type: $type) { - status - progress - score - } + try { + if (!token) { + throw new Error('AniList token is required'); } - `; - const res = await fetch('https://graphql.anilist.co', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query, - variables: { mediaId, type } - }) - }); + // 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 json = await res.json(); - return json?.data?.MediaList || null; -} \ No newline at end of file + 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.ok) { + throw new Error(`Failed to fetch entry: ${res.status}`); + } + + 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; + } +} diff --git a/src/api/books/books.service.ts b/src/api/books/books.service.ts index 541738a..e1c0b1d 100644 --- a/src/api/books/books.service.ts +++ b/src/api/books/books.service.ts @@ -1,5 +1,5 @@ import { getCachedExtension, cacheExtension, getCache, setCache, getExtensionTitle } from '../../shared/queries'; -import { queryOne, queryAll } from '../../shared/database'; +import { queryOne, queryAll, run } from '../../shared/database'; import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions'; import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types'; @@ -62,7 +62,10 @@ const MEDIA_FIELDS = ` `; export async function getBookById(id: string | number): Promise { - const row = await queryOne("SELECT full_data FROM books WHERE id = ?", [id]); + const row = await queryOne( + "SELECT full_data FROM books WHERE id = ?", + [id] + ); if (row) { return JSON.parse(row.full_data); @@ -70,6 +73,7 @@ export async function getBookById(id: string | number): Promise { let finalList: ListEntryData[] = [...dbList]; if (connected) { - const token = await getActiveAccessToken(userId); - const anilistEntries = await aniListService.getUserAniList(userId); const localWithoutAnilist = dbList.filter( @@ -184,30 +204,22 @@ export async function getUserList(userId: number): Promise { } } -export async function deleteListEntry(userId: number, entryId: string | number) { +export async function deleteListEntry( + userId: number, + entryId: string | number, + source: string +) { - const checkSql = ` - SELECT source - FROM ListEntry - WHERE user_id = ? AND entry_id = ?; - `; + if (source === 'anilist') { - const existing = await queryOne(checkSql, [userId, entryId], USER_DB) as any; - if (existing?.source === 'anilist') { - const sql = ` - SELECT access_token, anilist_user_id - FROM UserIntegration - WHERE user_id = ? AND platform = 'AniList'; - `; + const token = await getActiveAccessToken(userId); - const integration = await queryOne(sql, [userId], USER_DB) as any; - - if (integration?.access_token) { + if (token) { try { await aniListService.deleteAniListEntry( - integration.access_token, - Number(entryId) + token, + Number(entryId), ); return { success: true, external: true }; @@ -217,18 +229,14 @@ export async function deleteListEntry(userId: number, entryId: string | number) } } + // ✅ SOLO LOCAL const sql = ` DELETE FROM ListEntry WHERE user_id = ? AND entry_id = ?; `; - try { - const result = await run(sql, [userId, entryId], USER_DB); - return { success: result.changes > 0, changes: result.changes, external: false }; - } catch (error) { - console.error("Error al eliminar la entrada de lista:", error); - throw new Error("Error en la base de datos al eliminar la entrada."); - } + const result = await run(sql, [userId, entryId], USER_DB); + return { success: result.changes > 0, changes: result.changes, external: false }; } export async function getSingleListEntry( @@ -238,19 +246,61 @@ export async function getSingleListEntry( entryType: string ): Promise { - const connected = await isConnected(userId); + // ✅ 1. BUSCAR PRIMERO EN TU BASE DE DATOS + const localSql = ` + SELECT * FROM ListEntry + WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?; + `; - if (source === 'anilist' && connected) { + 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, anilist_user_id + SELECT access_token FROM UserIntegration WHERE user_id = ? AND platform = 'AniList'; `; const integration = await queryOne(sql, [userId], USER_DB) as any; - - if (!integration) return null; + if (!integration?.access_token) return null; const aniEntry = await aniListService.getSingleAniListEntry( integration.access_token, @@ -258,118 +308,38 @@ export async function getSingleListEntry( entryType as any ); + if (!aniEntry) return null; - let contentDetails: any = null; + const contentDetails: any = + entryType === 'ANIME' + ? await animeService.getAnimeById(entryId).catch(() => null) + : await booksService.getBookById(entryId).catch(() => null); - if (entryType === 'ANIME') { - const anime:any = await animeService.getAnimeById(entryId); + let finalTitle = contentDetails?.title || 'Unknown'; + let finalPoster = contentDetails?.coverImage?.extraLarge || + contentDetails?.image || + 'https://placehold.co/400x600?text=No+Cover'; - contentDetails = { - title: anime?.title, - poster: anime?.coverImage?.extraLarge || anime?.image || '', - total_episodes: anime?.episodes || anime?.nextAiringEpisode?.episode - 1 || 0, - }; - } else { - const book: any = await booksService.getBookById(entryId); - - contentDetails = { - title: book?.title, - poster: book?.coverImage?.extraLarge || book?.image || '', - total_chapters: book?.chapters || book?.volumes * 10 || 0, - }; - } - - 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'; + if (typeof finalTitle === 'object') { + finalTitle = + finalTitle.userPreferred || + finalTitle.english || + finalTitle.romaji || + 'Unknown'; } return { user_id: userId, - entry_id: Number(entryId), - source: 'anilist', - entry_type: entryType, - status: aniEntry.status, - progress: aniEntry.progress, - score: aniEntry.score, + ...aniEntry, title: finalTitle, poster: finalPoster, - total_episodes: contentDetails?.total_episodes, - total_chapters: contentDetails?.total_chapters, + total_episodes: contentDetails?.episodes, + total_chapters: contentDetails?.chapters, }; } - const sql = ` - SELECT * FROM ListEntry - WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?; - `; - - const dbEntry = await queryAll(sql, [userId, entryId, source, entryType], USER_DB) as ListEntryData[]; - - if (!dbEntry || dbEntry.length === 0) return null; - - const entry = dbEntry[0]; - - let contentDetails: any | null = null; - - try { - if (entryType === 'ANIME') { - let anime: any; - - if (source === 'anilist') { - anime = await animeService.getAnimeById(entryId); - } else { - const ext = getExtension(source); - anime = await animeService.getAnimeInfoExtension(ext, entryId.toString()); - } - - contentDetails = { - title: anime?.title, - poster: anime?.coverImage?.extraLarge || anime?.image || '', - total_episodes: anime?.episodes || anime?.nextAiringEpisode?.episode - 1 || 0, - }; - - } else { - let book: any; - - if (source === 'anilist') { - book = await booksService.getBookById(entryId); - } else { - const ext = getExtension(source); - book = await booksService.getBookInfoExtension(ext, entryId.toString()); - } - - contentDetails = { - title: book?.title, - poster: book?.coverImage?.extraLarge || book?.image || '', - total_chapters: book?.chapters || book?.volumes * 10 || 0, - }; - } - - } catch { - contentDetails = { - title: 'Unknown', - 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 null; } export async function getActiveAccessToken(userId: number): Promise { diff --git a/src/metadata/anilist_anime.db b/src/metadata/anilist_anime.db deleted file mode 100644 index 10d6d96..0000000 Binary files a/src/metadata/anilist_anime.db and /dev/null differ diff --git a/src/scripts/anime/anime.js b/src/scripts/anime/anime.js index d44a86d..be8ba87 100644 --- a/src/scripts/anime/anime.js +++ b/src/scripts/anime/anime.js @@ -34,8 +34,8 @@ function getSimpleAuthHeaders() { }; } +// Lógica de chequeo de lista corregida para usar /list/entry/{id} y esperar 'found' async function checkIfInList() { - const entryId = window.location.pathname.split('/').pop(); const source = extensionName || 'anilist'; const entryType = 'ANIME'; @@ -51,8 +51,7 @@ async function checkIfInList() { if (response.ok) { const data = await response.json(); - if (data.found && data.entry) { - + if (data.found && data.entry) { // Esperamos 'found' isInList = true; currentListEntry = data.entry; } else { @@ -69,6 +68,8 @@ async function checkIfInList() { function updateAddToListButton() { const btn = document.getElementById('add-to-list-btn'); + if (!btn) return; + if (isInList) { btn.innerHTML = ` @@ -87,36 +88,73 @@ function updateAddToListButton() { } } +// Función openAddToListModal actualizada con todos los campos extendidos function openAddToListModal() { - if (isInList) { + // Referencias + const modalTitle = document.getElementById('modal-title'); + const deleteBtn = document.getElementById('modal-delete-btn'); - document.getElementById('modal-status').value = currentListEntry.status || 'PLANNING'; - document.getElementById('modal-progress').value = currentListEntry.progress || 0; - document.getElementById('modal-score').value = currentListEntry.score || ''; + // Mapeo de prefijos (usamos 'entry-' para el HTML del modal extendido) + const statusEl = document.getElementById('entry-status'); + const progressEl = document.getElementById('entry-progress'); + const scoreEl = document.getElementById('entry-score'); + const startDateEl = document.getElementById('entry-start-date'); + const endDateEl = document.getElementById('entry-end-date'); + const repeatCountEl = document.getElementById('entry-repeat-count'); + const notesEl = document.getElementById('entry-notes'); + const privateEl = document.getElementById('entry-is-private'); - document.getElementById('modal-title').textContent = 'Edit List Entry'; - document.getElementById('modal-delete-btn').style.display = 'block'; + + if (isInList && currentListEntry) { + statusEl.value = currentListEntry.status || 'PLANNING'; + progressEl.value = currentListEntry.progress || 0; + scoreEl.value = currentListEntry.score || ''; + + // Campos extendidos + startDateEl.value = currentListEntry.start_date ? currentListEntry.start_date.split('T')[0] : ''; + endDateEl.value = currentListEntry.end_date ? currentListEntry.end_date.split('T')[0] : ''; + repeatCountEl.value = currentListEntry.repeat_count || 0; + notesEl.value = currentListEntry.notes || ''; + privateEl.checked = currentListEntry.is_private === true || currentListEntry.is_private === 1; + + modalTitle.textContent = 'Edit List Entry'; + deleteBtn.style.display = 'block'; } else { + // Valores por defecto + statusEl.value = 'PLANNING'; + progressEl.value = 0; + scoreEl.value = ''; + startDateEl.value = ''; + endDateEl.value = ''; + repeatCountEl.value = 0; + notesEl.value = ''; + privateEl.checked = false; - document.getElementById('modal-status').value = 'PLANNING'; - document.getElementById('modal-progress').value = 0; - document.getElementById('modal-score').value = ''; - - document.getElementById('modal-title').textContent = 'Add to List'; - document.getElementById('modal-delete-btn').style.display = 'none'; + modalTitle.textContent = 'Add to List'; + deleteBtn.style.display = 'none'; } - document.getElementById('modal-progress').max = totalEpisodes || 999; - document.getElementById('add-list-modal').classList.add('active');} + progressEl.max = totalEpisodes || 999; + document.getElementById('add-list-modal').classList.add('active'); +} function closeAddToListModal() { document.getElementById('add-list-modal').classList.remove('active'); } +// Función saveToList actualizada con todos los campos extendidos async function saveToList() { - const status = document.getElementById('modal-status').value; - const progress = parseInt(document.getElementById('modal-progress').value) || 0; - const score = parseFloat(document.getElementById('modal-score').value) || null; + const status = document.getElementById('entry-status').value; + const progress = parseInt(document.getElementById('entry-progress').value) || 0; + const scoreValue = document.getElementById('entry-score').value; + const score = scoreValue ? parseFloat(scoreValue) : null; + + // Nuevos campos + const start_date = document.getElementById('entry-start-date').value || null; + const end_date = document.getElementById('entry-end-date').value || null; + const repeat_count = parseInt(document.getElementById('entry-repeat-count').value) || 0; + const notes = document.getElementById('entry-notes').value || null; + const is_private = document.getElementById('entry-is-private').checked; try { const response = await fetch(`${API_BASE}/list/entry`, { @@ -128,7 +166,13 @@ async function saveToList() { entry_type: 'ANIME', status: status, progress: progress, - score: score + score: score, + // Campos extendidos + start_date: start_date, + end_date: end_date, + repeat_count: repeat_count, + notes: notes, + is_private: is_private }) }); @@ -136,16 +180,10 @@ async function saveToList() { throw new Error('Failed to save entry'); } - isInList = true; - currentListEntry = { - entry_id: parseInt(animeId), - source: extensionName || 'anilist', - entry_type: 'ANIME', + const data = await response.json(); - status, - progress, - score - }; + isInList = true; + currentListEntry = data.entry; updateAddToListButton(); closeAddToListModal(); showNotification(isInList ? 'Updated successfully!' : 'Added to your list!', 'success'); @@ -158,8 +196,11 @@ async function saveToList() { async function deleteFromList() { if (!confirm('Remove this anime from your list?')) return; + const source = extensionName || 'anilist'; + const entryType = 'ANIME'; + try { - const response = await fetch(`${API_BASE}/list/entry/${animeId}`, { + const response = await fetch(`${API_BASE}/list/entry/${animeId}?source=${source}&entry_type=${entryType}`, { method: 'DELETE', headers: getSimpleAuthHeaders() diff --git a/src/scripts/books/book.js b/src/scripts/books/book.js index 1b55391..a722205 100644 --- a/src/scripts/books/book.js +++ b/src/scripts/books/book.js @@ -46,6 +46,7 @@ function getBookEntryType(bookData) { return (format === 'MANGA' || format === 'ONE_SHOT' || format === 'MANHWA') ? 'MANGA' : 'NOVEL'; } +// CORRECCIÓN: Usar el endpoint /list/entry/{id} y esperar 'found' async function checkIfInList() { if (!currentBookData) return; @@ -54,6 +55,7 @@ async function checkIfInList() { const entryType = getBookEntryType(currentBookData); + // URL CORRECTA: /list/entry/{id}?source={source}&entry_type={entryType} const fetchUrl = `${API_BASE}/list/entry/${entryId}?source=${source}&entry_type=${entryType}`; try { @@ -64,6 +66,7 @@ async function checkIfInList() { if (response.ok) { const data = await response.json(); + // LÓGICA CORRECTA: Comprobar data.found if (data.found && data.entry) { isInList = true; @@ -73,6 +76,11 @@ async function checkIfInList() { currentListEntry = null; } updateAddToListButton(); + } else if (response.status === 404) { + // Manejar 404 como 'no encontrado' si la API lo devuelve así + isInList = false; + currentListEntry = null; + updateAddToListButton(); } } catch (error) { console.error('Error checking single list entry:', error); @@ -103,40 +111,71 @@ function updateAddToListButton() { } } +/** + * REFACTORIZADO para usar la estructura del modal completo. + * Asume que el HTML usa IDs como 'entry-status', 'entry-progress', 'entry-score', etc. + */ function openAddToListModal() { if (!currentBookData) return; const totalUnits = currentBookData.chapters || currentBookData.volumes || 999; + const entryType = getBookEntryType(currentBookData); - if (isInList) { + // Referencias a los elementos del nuevo modal (usando 'entry-' prefix) + const modalTitle = document.getElementById('modal-title'); + const deleteBtn = document.getElementById('modal-delete-btn'); + const progressLabel = document.getElementById('progress-label'); - document.getElementById('modal-status').value = currentListEntry.status || 'PLANNING'; - document.getElementById('modal-progress').value = currentListEntry.progress || 0; - document.getElementById('modal-score').value = currentListEntry.score || ''; - - document.getElementById('modal-title').textContent = 'Edit Library Entry'; - document.getElementById('modal-delete-btn').style.display = 'block'; - } else { - - document.getElementById('modal-status').value = 'PLANNING'; - document.getElementById('modal-progress').value = 0; - document.getElementById('modal-score').value = ''; - - document.getElementById('modal-title').textContent = 'Add to Library'; - document.getElementById('modal-delete-btn').style.display = 'none'; + // **VERIFICACIÓN CRÍTICA** + if (!modalTitle || !deleteBtn || !progressLabel) { + console.error("Error: Uno o más elementos críticos del modal (título, botón eliminar, o etiqueta de progreso) no se encontraron. Verifique los IDs en el HTML."); + return; } - const progressLabel = document.getElementById('modal-progress-label'); + // --- Población de Datos --- + + if (isInList && currentListEntry) { + // Datos comunes + document.getElementById('entry-status').value = currentListEntry.status || 'PLANNING'; + document.getElementById('entry-progress').value = currentListEntry.progress || 0; + document.getElementById('entry-score').value = currentListEntry.score || ''; + + // Nuevos datos + // Usar formato ISO si viene como ISO, o limpiar si es necesario. Tu ejemplo JSON no tenía fechas. + document.getElementById('entry-start-date').value = currentListEntry.start_date ? currentListEntry.start_date.split('T')[0] : ''; + document.getElementById('entry-end-date').value = currentListEntry.end_date ? currentListEntry.end_date.split('T')[0] : ''; + document.getElementById('entry-repeat-count').value = currentListEntry.repeat_count || 0; + document.getElementById('entry-notes').value = currentListEntry.notes || ''; + document.getElementById('entry-is-private').checked = currentListEntry.is_private === true || currentListEntry.is_private === 1; + + modalTitle.textContent = 'Edit Library Entry'; + deleteBtn.style.display = 'block'; + } else { + // Valores por defecto + document.getElementById('entry-status').value = 'PLANNING'; + document.getElementById('entry-progress').value = 0; + document.getElementById('entry-score').value = ''; + document.getElementById('entry-start-date').value = ''; + document.getElementById('entry-end-date').value = ''; + document.getElementById('entry-repeat-count').value = 0; + document.getElementById('entry-notes').value = ''; + document.getElementById('entry-is-private').checked = false; + + modalTitle.textContent = 'Add to Library'; + deleteBtn.style.display = 'none'; + } + + // --- Configuración de Etiquetas y Máximo --- + if (progressLabel) { - const format = currentBookData.format?.toUpperCase() || 'MANGA'; - if (format === 'MANGA' || format === 'ONE_SHOT' || format === 'MANHWA') { + if (entryType === 'MANGA') { progressLabel.textContent = 'Chapters Read'; } else { progressLabel.textContent = 'Volumes/Parts Read'; } } - document.getElementById('modal-progress').max = totalUnits; + document.getElementById('entry-progress').max = totalUnits; document.getElementById('add-list-modal').classList.add('active'); } @@ -144,10 +183,23 @@ function closeAddToListModal() { document.getElementById('add-list-modal').classList.remove('active'); } +/** + * REFACTORIZADO para guardar TODOS los campos del modal. + */ async function saveToList() { - const status = document.getElementById('modal-status').value; - const progress = parseInt(document.getElementById('modal-progress').value) || 0; - const score = parseFloat(document.getElementById('modal-score').value) || null; + // Datos comunes + const status = document.getElementById('entry-status').value; + const progress = parseInt(document.getElementById('entry-progress').value) || 0; + const scoreValue = document.getElementById('entry-score').value; + const score = scoreValue ? parseFloat(scoreValue) : null; + + // Nuevos datos + const start_date = document.getElementById('entry-start-date').value || null; + const end_date = document.getElementById('entry-end-date').value || null; + const repeat_count = parseInt(document.getElementById('entry-repeat-count').value) || 0; + const notes = document.getElementById('entry-notes').value || null; + const is_private = document.getElementById('entry-is-private').checked; + if (!currentBookData) { showNotification('Cannot save: Book data not loaded.', 'error'); @@ -167,7 +219,13 @@ async function saveToList() { entry_type: entryType, status: status, progress: progress, - score: score + score: score, + // Nuevos campos + start_date: start_date, + end_date: end_date, + repeat_count: repeat_count, + notes: notes, + is_private: is_private }) }); @@ -175,8 +233,10 @@ async function saveToList() { throw new Error('Failed to save entry'); } + const data = await response.json(); + isInList = true; - currentListEntry = { entry_id: idToSave, source: extensionName || 'anilist', entry_type: entryType, status, progress, score }; + currentListEntry = data.entry; // Usar la respuesta del servidor si está disponible updateAddToListButton(); closeAddToListModal(); showNotification(isInList ? 'Updated successfully!' : 'Added to your library!', 'success'); @@ -186,16 +246,19 @@ async function saveToList() { } } +// CORRECCIÓN: Usar el endpoint /list/entry/{id} con los parámetros correctos. async function deleteFromList() { if (!confirm('Remove this book from your library?')) return; const idToDelete = extensionName ? bookSlug : bookId; + const source = extensionName || 'anilist'; + const entryType = getBookEntryType(currentBookData); // Obtener el tipo de entrada try { - const response = await fetch(`${API_BASE}/list/entry/${idToDelete}`, { + // URL CORRECTA para DELETE: /list/entry/{id}?source={source}&entry_type={entryType} + const response = await fetch(`${API_BASE}/list/entry/${idToDelete}?source=${source}&entry_type=${entryType}`, { method: 'DELETE', headers: getSimpleAuthHeaders() - }); if (!response.ok) { @@ -519,6 +582,7 @@ function openReader(bookId, chapterId, provider) { } document.addEventListener('DOMContentLoaded', () => { + // El ID del modal sigue siendo 'add-list-modal' para mantener la compatibilidad con el código original. const modal = document.getElementById('add-list-modal'); if (modal) { modal.addEventListener('click', (e) => { diff --git a/src/scripts/list.js b/src/scripts/list.js index 964598a..a15fa23 100644 --- a/src/scripts/list.js +++ b/src/scripts/list.js @@ -224,6 +224,7 @@ function createListItem(item) { const progressPercent = totalUnits > 0 ? (progress / totalUnits) * 100 : 0; const score = item.score ? item.score.toFixed(1) : null; + const repeatCount = item.repeat_count || 0; const entryType = (item.entry_type || 'ANIME').toUpperCase(); let unitLabel = 'units'; @@ -243,6 +244,15 @@ function createListItem(item) { 'DROPPED': 'Dropped' }; + const extraInfo = []; + if (repeatCount > 0) { + extraInfo.push(`🔁 ${repeatCount}`); + } + if (item.is_private) { + extraInfo.push('🔒 Private'); + } + + div.innerHTML = ` ${item.title || 'Entry'} @@ -256,6 +266,7 @@ function createListItem(item) { ${statusLabels[item.status] || item.status} ${entryType} ${item.source.toUpperCase()} + ${extraInfo.join('')} @@ -282,9 +293,21 @@ function createListItem(item) { function openEditModal(item) { currentEditingEntry = item; + // Campos existentes document.getElementById('edit-status').value = item.status; document.getElementById('edit-progress').value = item.progress || 0; - document.getElementById('edit-score').value = item.score || ''; + // Asegura que el score se muestre si existe. + document.getElementById('edit-score').value = item.score !== null && item.score !== undefined ? item.score : ''; + + // Nuevos campos + // Usamos split('T')[0] para asegurar que solo se muestra la parte de la fecha (YYYY-MM-DD) si viene con formato DATETIME. + document.getElementById('edit-start-date').value = item.start_date?.split('T')[0] || ''; + document.getElementById('edit-end-date').value = item.end_date?.split('T')[0] || ''; + document.getElementById('edit-repeat-count').value = item.repeat_count || 0; + document.getElementById('edit-notes').value = item.notes || ''; + // Maneja el booleano o el entero (1/0) + document.getElementById('edit-is-private').checked = item.is_private === 1 || item.is_private === true; + const entryType = (item.entry_type || 'ANIME').toUpperCase(); const progressLabel = document.querySelector('label[for="edit-progress"]'); @@ -315,9 +338,20 @@ function closeEditModal() { async function saveEntry() { if (!currentEditingEntry) return; + // Campos existentes const status = document.getElementById('edit-status').value; const progress = parseInt(document.getElementById('edit-progress').value) || 0; - const score = parseFloat(document.getElementById('edit-score').value) || null; + // Usar null si el score está vacío + const scoreValue = document.getElementById('edit-score').value; + const score = scoreValue ? parseFloat(scoreValue) : null; + + // Nuevos campos + const start_date = document.getElementById('edit-start-date').value || null; + const end_date = document.getElementById('edit-end-date').value || null; + const repeat_count = parseInt(document.getElementById('edit-repeat-count').value) || 0; + const notesValue = document.getElementById('edit-notes').value; + const notes = notesValue ? notesValue : null; + const is_private = document.getElementById('edit-is-private').checked; try { const response = await fetch(`${API_BASE}/list/entry`, { @@ -329,7 +363,13 @@ async function saveEntry() { entry_type: currentEditingEntry.entry_type || 'ANIME', status: status, progress: progress, - score: score + score: score, + // Nuevos datos a enviar al backend + start_date: start_date, + end_date: end_date, + repeat_count: repeat_count, + notes: notes, + is_private: is_private }) }); @@ -354,10 +394,13 @@ async function deleteEntry() { } try { - const response = await fetch(`${API_BASE}/list/entry/${currentEditingEntry.entry_id}`, { - method: 'DELETE', - headers: getSimpleAuthHeaders() - }); + const response = await fetch( + `${API_BASE}/list/entry/${currentEditingEntry.entry_id}?source=${currentEditingEntry.source}`, + { + method: 'DELETE', + headers: getSimpleAuthHeaders() + } + ); if (!response.ok) { throw new Error('Failed to delete entry'); @@ -372,6 +415,7 @@ async function deleteEntry() { } } + function showNotification(message, type = 'info') { const notification = document.createElement('div'); notification.style.cssText = ` diff --git a/src/shared/schemas.js b/src/shared/schemas.js index c0997ce..30676fa 100644 --- a/src/shared/schemas.js +++ b/src/shared/schemas.js @@ -46,6 +46,12 @@ async function ensureUserDataDB(dbPath) { 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 @@ -89,8 +95,6 @@ async function ensureAnilistSchema(db) { id INTEGER PRIMARY KEY, title TEXT, updatedAt INTEGER, - cache_created_at INTEGER DEFAULT 0, - cache_ttl_ms INTEGER DEFAULT 0, full_data JSON ); diff --git a/views/anime/anime.html b/views/anime/anime.html index 4a49c33..afbdf8c 100644 --- a/views/anime/anime.html +++ b/views/anime/anime.html @@ -23,7 +23,6 @@ - -