import { mmkvStorage } from '../mmkvStorage'; import { MalApiService } from './MalApi'; import { MalListStatus } from '../../types/mal'; import { catalogService } from '../catalogService'; import { mappingService } from '../MappingService'; import axios from 'axios'; const MAPPING_PREFIX = 'mal_map_'; export const MalSync = { /** * Tries to find a MAL ID using IMDb ID via MAL-Sync API. */ getMalIdFromImdb: async (imdbId: string): Promise => { if (!imdbId) return null; // 1. Check Cache const cacheKey = `${MAPPING_PREFIX}imdb_${imdbId}`; const cachedId = mmkvStorage.getNumber(cacheKey); if (cachedId) return cachedId; // 2. Fetch from MAL-Sync API try { // Ensure ID format const cleanId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; const response = await axios.get(`https://api.malsync.moe/mal/anime/imdb/${cleanId}`); if (response.data && response.data.id) { const malId = response.data.id; // Save to cache mmkvStorage.setNumber(cacheKey, malId); return malId; } } catch (e) { // Ignore errors (404, etc.) } return null; }, /** * Tries to find a MAL ID for a given anime title or IMDb ID. * Caches the result to avoid repeated API calls. */ getMalId: async (title: string, type: 'movie' | 'series' = 'series', year?: number, season?: number, imdbId?: string, episode: number = 1): Promise => { // Safety check: Never perform a MAL search for generic placeholders or empty strings. // This prevents "cache poisoning" where a generic term matches a random anime. const normalizedTitle = title.trim().toLowerCase(); const isGenericTitle = !normalizedTitle || normalizedTitle === 'anime' || normalizedTitle === 'movie'; if (isGenericTitle) { // If we have an offline mapping, we can still try it below, // but we MUST skip the fuzzy search logic at the end. if (!imdbId) return null; } // 1. Try Offline Mapping Service (Most accurate for perfect season/episode matching) if (imdbId && type === 'series' && season !== undefined) { const offlineMalId = mappingService.getMalId(imdbId, season, episode); if (offlineMalId) { console.log(`[MalSync] Found offline mapping: ${imdbId} S${season}E${episode} -> MAL ${offlineMalId}`); return offlineMalId; } } // 2. Try IMDb ID first (Via online MalSync API) - BUT only for Season 1 or Movies. // 2. Check Cache for Title const cleanTitle = title.trim(); const cacheKey = `${MAPPING_PREFIX}${cleanTitle}_${type}_${season || 1}`; const cachedId = mmkvStorage.getNumber(cacheKey); if (cachedId) return cachedId; // 3. Search MAL (Skip if generic title) if (isGenericTitle) return null; try { let searchQuery = cleanTitle; // For Season 2+, explicitly search for that season if (type === 'series' && season && season > 1) { // Improve search query: "Attack on Titan Season 2" usually works better than just appending searchQuery = `${cleanTitle} Season ${season}`; } const result = await MalApiService.searchAnime(searchQuery, 10); if (result.data.length > 0) { let candidates = result.data; // Filter by type first if (type === 'movie') { candidates = candidates.filter(r => r.node.media_type === 'movie'); } else { candidates = candidates.filter(r => r.node.media_type === 'tv' || r.node.media_type === 'ona' || r.node.media_type === 'special' || r.node.media_type === 'ova'); } if (candidates.length === 0) candidates = result.data; // Fallback to all if type filtering removes everything let bestMatch = candidates[0].node; // If year is provided, try to find an exact start year match if (year) { const yearMatch = candidates.find(r => r.node.start_season?.year === year); if (yearMatch) { bestMatch = yearMatch.node; } else { // Fuzzy year match (+/- 1 year) const fuzzyMatch = candidates.find(r => r.node.start_season?.year && Math.abs(r.node.start_season.year - year) <= 1); if (fuzzyMatch) bestMatch = fuzzyMatch.node; } } // Save to cache mmkvStorage.setNumber(cacheKey, bestMatch.id); return bestMatch.id; } } catch (e) { console.warn('MAL Search failed for', title); } return null; }, /** * Main function to track progress */ scrobbleEpisode: async ( animeTitle: string, episodeNumber: number, totalEpisodes: number = 0, type: 'movie' | 'series' = 'series', season?: number, imdbId?: string ) => { try { // Requirement 9 & 10: Respect user settings and safety const isEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true; const isAutoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true; if (!isEnabled || !isAutoUpdate) { return; } const malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber); if (!malId) return; // Check current status on MAL to avoid overwriting completed/dropped shows try { const currentInfo = await MalApiService.getMyListStatus(malId); const currentStatus = currentInfo.my_list_status?.status; const currentEpisodesWatched = currentInfo.my_list_status?.num_episodes_watched || 0; // Requirement 4: Auto-Add Anime to MAL (Configurable) if (!currentStatus) { const autoAdd = mmkvStorage.getBoolean('mal_auto_add') ?? true; if (!autoAdd) { console.log(`[MalSync] Skipping scrobble for ${animeTitle}: Not in list and auto-add disabled`); return; } } // If already completed or dropped, don't auto-update via scrobble if (currentStatus === 'completed' || currentStatus === 'dropped') { console.log(`[MalSync] Skipping update for ${animeTitle}: Status is ${currentStatus}`); return; } // If we are just starting (ep 1) or resuming (plan_to_watch/on_hold/null), set to watching // Also ensure we don't downgrade episode count (though unlikely with scrobbling forward) if (episodeNumber <= currentEpisodesWatched) { console.log(`[MalSync] Skipping update for ${animeTitle}: Episode ${episodeNumber} <= Current ${currentEpisodesWatched}`); return; } } catch (e) { // If error (e.g. not found), proceed to add it } let finalTotalEpisodes = totalEpisodes; // If totalEpisodes not provided, try to fetch it from MAL details if (finalTotalEpisodes <= 0) { try { const details = await MalApiService.getAnimeDetails(malId); if (details && details.num_episodes) { finalTotalEpisodes = details.num_episodes; } } catch (e) { // Fallback to 0 if details fetch fails } } // Determine Status let status: MalListStatus = 'watching'; if (finalTotalEpisodes > 0 && episodeNumber >= finalTotalEpisodes) { status = 'completed'; } await MalApiService.updateStatus(malId, status, episodeNumber); console.log(`[MalSync] Synced ${animeTitle} Ep ${episodeNumber}/${finalTotalEpisodes || '?'} -> MAL ID ${malId} (${status})`); } catch (e) { console.error('[MalSync] Scrobble failed:', e); } }, /** * Direct scrobble with known MAL ID and Episode * Used when ArmSync has already resolved the exact details. */ scrobbleDirect: async (malId: number, episodeNumber: number) => { try { // Respect user settings const isEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true; const isAutoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true; if (!isEnabled || !isAutoUpdate) return; // Check current status const currentInfo = await MalApiService.getMyListStatus(malId); const currentStatus = currentInfo.my_list_status?.status; // Auto-Add check if (!currentStatus) { const autoAdd = mmkvStorage.getBoolean('mal_auto_add') ?? true; if (!autoAdd) { console.log(`[MalSync] Skipping direct scrobble: Not in list and auto-add disabled`); return; } } // Safety checks (Completed/Dropped/Regression) if (currentStatus === 'completed' || currentStatus === 'dropped') return; if (currentInfo.my_list_status?.num_episodes_watched && episodeNumber <= currentInfo.my_list_status.num_episodes_watched) return; // Determine Status let status: MalListStatus = 'watching'; if (currentInfo.num_episodes > 0 && episodeNumber >= currentInfo.num_episodes) { status = 'completed'; } await MalApiService.updateStatus(malId, status, episodeNumber); console.log(`[MalSync] Direct synced MAL ID ${malId} Ep ${episodeNumber} (${status})`); } catch (e) { console.error('[MalSync] Direct scrobble failed:', e); } }, /** * Import MAL list items into local library */ syncMalToLibrary: async () => { try { let allItems: MalAnimeNode[] = []; let offset = 0; let hasMore = true; while (hasMore && offset < 1000) { // Limit to 1000 items for safety const response = await MalApiService.getUserList(undefined, offset, 100); if (response.data && response.data.length > 0) { allItems = [...allItems, ...response.data]; offset += response.data.length; hasMore = !!response.paging.next; } else { hasMore = false; } } for (const item of allItems) { const type = item.node.media_type === 'movie' ? 'movie' : 'series'; const cacheKey = `${MAPPING_PREFIX}${item.node.title.trim()}_${type}`; mmkvStorage.setNumber(cacheKey, item.node.id); } console.log(`[MalSync] Synced ${allItems.length} items to mapping cache.`); return true; } catch (e) { console.error('syncMalToLibrary failed', e); return false; } }, /** * Manually map an ID if auto-detection fails */ setMapping: (title: string, malId: number, type: 'movie' | 'series' = 'series') => { const cacheKey = `${MAPPING_PREFIX}${title.trim()}_${type}`; mmkvStorage.setNumber(cacheKey, malId); }, /** * Get external IDs (IMDb, etc.) and season info from a MAL ID using MalSync API */ getIdsFromMalId: async (malId: number): Promise<{ imdbId: string | null; season: number }> => { const cacheKey = `mal_ext_ids_v2_${malId}`; const cached = mmkvStorage.getString(cacheKey); if (cached) { return JSON.parse(cached); } try { const response = await axios.get(`https://api.malsync.moe/mal/anime/${malId}`); const data = response.data; let imdbId = null; let season = data.season || 1; // Try to find IMDb ID in Sites if (data.Sites && data.Sites.IMDB) { const imdbKeys = Object.keys(data.Sites.IMDB); if (imdbKeys.length > 0) { imdbId = imdbKeys[0]; } } const result = { imdbId, season }; mmkvStorage.setString(cacheKey, JSON.stringify(result)); return result; } catch (e) { console.error('[MalSync] Failed to fetch external IDs:', e); } return { imdbId: null, season: 1 }; }, /** * Get weekly anime schedule from Jikan API (Adjusted to Local Timezone) */ getWeeklySchedule: async (): Promise => { const cacheKey = 'mal_weekly_schedule_local_v2'; // Bump version for new format const cached = mmkvStorage.getString(cacheKey); const cacheTime = mmkvStorage.getNumber(`${cacheKey}_time`); // Cache for 24 hours if (cached && cacheTime && (Date.now() - cacheTime < 24 * 60 * 60 * 1000)) { return JSON.parse(cached); } try { // Jikan API rate limit mitigation await new Promise(resolve => setTimeout(resolve, 500)); const response = await axios.get('https://api.jikan.moe/v4/schedules'); const data = response.data.data; const daysOrder = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays']; const dayMap: Record = { 'Mondays': 0, 'Tuesdays': 1, 'Wednesdays': 2, 'Thursdays': 3, 'Fridays': 4, 'Saturdays': 5, 'Sundays': 6 }; const daysReverse = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays']; const grouped: Record = {}; // Calculate time difference in minutes: Local - JST (UTC+9) // getTimezoneOffset() returns minutes BEHIND UTC (positive for US, negative for Asia) // We want Local - UTC+9. // Local = UTC - offset. // Diff = (UTC - localOffset) - (UTC + 540) = -localOffset - 540. const jstOffset = 540; // UTC+9 in minutes const localOffset = new Date().getTimezoneOffset(); // e.g. 300 for EST (UTC-5) const offsetMinutes = -localOffset - jstOffset; // e.g. -300 - 540 = -840 minutes (-14h) data.forEach((anime: any) => { let day = anime.broadcast?.day; // "Mondays" let time = anime.broadcast?.time; // "23:00" let originalDay = day; // Adjust to local time if (day && time && dayMap[day] !== undefined) { const [hours, mins] = time.split(':').map(Number); let totalMinutes = hours * 60 + mins + offsetMinutes; let dayShift = 0; // Handle day rollovers if (totalMinutes < 0) { totalMinutes += 24 * 60; dayShift = -1; } else if (totalMinutes >= 24 * 60) { totalMinutes -= 24 * 60; dayShift = 1; } const newHour = Math.floor(totalMinutes / 60); const newMin = totalMinutes % 60; time = `${String(newHour).padStart(2,'0')}:${String(newMin).padStart(2,'0')}`; let dayIndex = dayMap[day] + dayShift; if (dayIndex < 0) dayIndex = 6; if (dayIndex > 6) dayIndex = 0; day = daysReverse[dayIndex]; } else { day = 'Other'; // No specific time/day } if (!grouped[day]) grouped[day] = []; grouped[day].push({ id: `mal:${anime.mal_id}`, seriesId: `mal:${anime.mal_id}`, title: anime.title, seriesName: anime.title_english || anime.title, poster: anime.images?.jpg?.large_image_url || anime.images?.jpg?.image_url, releaseDate: null, season: 1, episode: 1, overview: anime.synopsis, vote_average: anime.score, day: day, time: time, genres: anime.genres?.map((g: any) => g.name) || [], originalDay: originalDay // Keep for debug if needed }); }); // Sort by day (starting Monday or Today?) -> Standard is Monday start for anime // Sort items by time within day const result = [...daysOrder, 'Other'] .filter(day => grouped[day] && grouped[day].length > 0) .map(day => ({ title: day, data: grouped[day].sort((a, b) => (a.time || '99:99').localeCompare(b.time || '99:99')) })); mmkvStorage.setString(cacheKey, JSON.stringify(result)); mmkvStorage.setNumber(`${cacheKey}_time`, Date.now()); return result; } catch (e) { console.error('[MalSync] Failed to fetch schedule:', e); return []; } } };