anilistService.ts
1 import AsyncStorage from '@react-native-async-storage/async-storage'; 2 import { getAuthData } from './anilistOAuth'; 3 import { decode } from 'html-entities'; 4 import { getMangaData } from './bookmarkService'; 5 import { 6 saveMangaMapping as saveMapping, 7 getAnilistIdFromInternalId as getMapping, 8 } from './mangaMappingService'; 9 10 const ANILIST_API_URL = 'https://graphql.anilist.co'; 11 12 interface AnilistManga { 13 id: number; 14 title: { 15 romaji: string; 16 english: string; 17 native: string; 18 }; 19 } 20 21 export const isLoggedInToAniList = async (): Promise<boolean> => { 22 try { 23 const authData = await getAuthData(); 24 return !!authData && Date.now() < authData.expiresAt; 25 } catch (error) { 26 console.error('Error checking AniList login status:', error); 27 return false; 28 } 29 }; 30 31 async function makeGraphQLRequest( 32 query: string, 33 variables: any, 34 accessToken?: string 35 ) { 36 const headers: Record<string, string> = { 37 'Content-Type': 'application/json', 38 Accept: 'application/json', 39 }; 40 41 if (accessToken) { 42 headers['Authorization'] = `Bearer ${accessToken}`; 43 } 44 45 const response = await fetch(ANILIST_API_URL, { 46 method: 'POST', 47 headers, 48 body: JSON.stringify({ query, variables }), 49 }); 50 51 const data = await response.json(); 52 if (data.errors) { 53 throw new Error(data.errors[0].message); 54 } 55 return data.data; 56 } 57 58 export async function searchAnilistMangaByName( 59 name: string 60 ): Promise<AnilistManga | null> { 61 const query = ` 62 query ($search: String) { 63 Media(search: $search, type: MANGA) { 64 id 65 title { 66 romaji 67 english 68 native 69 } 70 } 71 } 72 `; 73 74 const variables = { search: name }; 75 76 try { 77 const data = await makeGraphQLRequest(query, variables); 78 return data.Media || null; 79 } catch (error) { 80 console.error('Error searching AniList:', error); 81 return null; 82 } 83 } 84 85 export async function saveMangaMapping( 86 internalId: string, 87 anilistId: number, 88 title: string 89 ): Promise<void> { 90 try { 91 await saveMapping(internalId, anilistId, title); 92 } catch (error) { 93 console.error('Error saving manga mapping:', error); 94 } 95 } 96 97 export async function getAnilistIdFromInternalId( 98 internalId: string 99 ): Promise<number | null> { 100 try { 101 return await getMapping(internalId); 102 } catch (error) { 103 console.error('Error getting AniList ID:', error); 104 return null; 105 } 106 } 107 108 export async function updateMangaStatus( 109 mediaId: number, 110 status: string, 111 progress: number 112 ): Promise<void> { 113 const isLoggedIn = await isLoggedInToAniList(); 114 if (!isLoggedIn) { 115 console.log('User is not logged in to AniList. Skipping update.'); 116 return; 117 } 118 const authData = await getAuthData(); 119 if (!authData) { 120 throw new Error('User not authenticated with AniList'); 121 } 122 123 const query = ` 124 mutation ($mediaId: Int, $status: MediaListStatus, $progress: Int) { 125 SaveMediaListEntry (mediaId: $mediaId, status: $status, progress: $progress) { 126 id 127 status 128 progress 129 } 130 } 131 `; 132 133 const variables = { 134 mediaId, 135 status, 136 progress, 137 }; 138 139 try { 140 await makeGraphQLRequest(query, variables, authData.accessToken); 141 } catch (error) { 142 console.error('Error updating manga status on AniList:', error); 143 throw error; 144 } 145 } 146 147 export async function updateAniListStatus( 148 mangaTitle: string, 149 status: 'To Read' | 'Reading' | 'Read', 150 readChapters: string[], 151 totalChapters: number 152 ): Promise<{ success: boolean; message: string }> { 153 try { 154 const isLoggedIn = await isLoggedInToAniList(); 155 if (!isLoggedIn) { 156 console.log('User is not logged in to AniList. Skipping update.'); 157 return { 158 success: false, 159 message: `User is not logged in to AniList. Skipping update.`, 160 }; 161 } 162 163 const anilistManga = await searchAnilistMangaByName(mangaTitle); 164 if (anilistManga) { 165 let anilistStatus: string; 166 let progress: number = 0; 167 168 switch (status) { 169 case 'To Read': 170 anilistStatus = 'PLANNING'; 171 break; 172 case 'Reading': 173 anilistStatus = 'CURRENT'; 174 progress = readChapters.length; 175 break; 176 case 'Read': 177 anilistStatus = 'COMPLETED'; 178 progress = totalChapters; 179 break; 180 default: 181 anilistStatus = 'PLANNING'; 182 } 183 184 await updateMangaStatus(anilistManga.id, anilistStatus, progress); 185 console.log( 186 `Updated AniList status for ${mangaTitle} to ${anilistStatus}` 187 ); 188 return { 189 success: true, 190 message: `Updated AniList status for "${mangaTitle}" to ${status}`, 191 }; 192 } else { 193 console.log(`Manga ${mangaTitle} not found on AniList`); 194 return { 195 success: false, 196 message: `"${mangaTitle}" was not found on AniList. Only local status was updated.`, 197 }; 198 } 199 } catch (error) { 200 console.error('Error updating AniList status:', error); 201 return { 202 success: false, 203 message: `Error updating AniList status: ${error}`, 204 }; 205 } 206 } 207 208 export async function syncAllMangaWithAniList(): Promise<string[]> { 209 const debug = (message: string, data?: any) => { 210 console.log(`[Manga Sync] ${message}`, data || ''); 211 }; 212 213 try { 214 debug('Starting manga sync'); 215 const isLoggedIn = await isLoggedInToAniList(); 216 if (!isLoggedIn) { 217 throw new Error('User is not logged in to AniList'); 218 } 219 220 const bookmarkKeysString = await AsyncStorage.getItem('bookmarkKeys'); 221 if (!bookmarkKeysString) { 222 debug('No bookmarks found'); 223 return ['No bookmarked manga found']; 224 } 225 226 const bookmarkKeys = JSON.parse(bookmarkKeysString); 227 const results: string[] = []; 228 let successCount = 0; 229 let failureCount = 0; 230 231 for (const key of bookmarkKeys) { 232 try { 233 debug(`Processing manga key: ${key}`); 234 const id = key.replace('bookmark_', ''); 235 236 // Get manga data from our structured storage 237 const mangaData = await getMangaData(id); 238 239 if (!mangaData?.title || !mangaData?.bookmarkStatus) { 240 debug(`Missing data for manga ${id}`, { 241 title: !!mangaData?.title, 242 status: !!mangaData?.bookmarkStatus, 243 }); 244 results.push(`Skipped manga with ID ${id}: Missing title or status`); 245 failureCount++; 246 continue; 247 } 248 249 const decodedTitle = decode(mangaData.title).trim(); 250 debug(`Searching for manga: ${decodedTitle}`); 251 252 // Add retries for AniList search 253 let anilistManga = null; 254 let retries = 3; 255 while (retries > 0 && !anilistManga) { 256 try { 257 anilistManga = await searchAnilistMangaByName(decodedTitle); 258 if (!anilistManga && retries > 1) { 259 debug(`Retry ${4 - retries} for ${decodedTitle}`); 260 await new Promise((resolve) => setTimeout(resolve, 1000)); 261 } 262 } catch (error) { 263 debug(`Search error: ${error}`); 264 } 265 retries--; 266 } 267 268 if (!anilistManga) { 269 debug(`Manga not found: ${decodedTitle}`); 270 results.push(`Manga not found on AniList: ${decodedTitle}`); 271 failureCount++; 272 continue; 273 } 274 275 const anilistStatus = 276 { 277 'To Read': 'PLANNING', 278 Reading: 'CURRENT', 279 Read: 'COMPLETED', 280 'On Hold': 'PAUSED', 281 }[mangaData.bookmarkStatus] || 'PLANNING'; 282 283 const progress = mangaData.readChapters.length; 284 285 debug(`Updating status for ${decodedTitle}`, { 286 anilistStatus, 287 progress, 288 }); 289 await updateMangaStatus(anilistManga.id, anilistStatus, progress); 290 291 // Save the mapping for future use 292 await saveMangaMapping(id, anilistManga.id, decodedTitle); 293 294 results.push( 295 `Updated "${decodedTitle}" on AniList: Status=${anilistStatus}, Progress=${progress}` 296 ); 297 successCount++; 298 299 // Add delay between updates to prevent rate limiting 300 await new Promise((resolve) => setTimeout(resolve, 500)); 301 } catch (error) { 302 debug(`Error processing manga: ${error}`); 303 results.push(`Failed to update manga: ${error}`); 304 failureCount++; 305 } 306 } 307 308 debug('Sync completed', { success: successCount, failures: failureCount }); 309 results.unshift( 310 `Sync completed: ${successCount} succeeded, ${failureCount} failed` 311 ); 312 return results; 313 } catch (error) { 314 debug('Sync failed', error); 315 throw error; 316 } 317 }