/ services / anilistService.ts
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  }