From b093e4933c38b4242da3aec5d452c2d395770320 Mon Sep 17 00:00:00 2001 From: paregi12 Date: Thu, 12 Mar 2026 15:15:55 +0530 Subject: [PATCH] feat(mal): improved mapping logic with direct ID support, TMDB-based resolution, and same-day batch support --- src/components/metadata/SeriesContent.tsx | 32 +++++++- src/components/player/AndroidVideoPlayer.tsx | 20 ++++- src/components/player/KSPlayerCore.tsx | 20 ++++- .../player/hooks/useWatchProgress.ts | 30 +++++++- src/services/catalogService.ts | 7 ++ src/services/mal/ArmSyncService.ts | 75 ++++++++++++++++--- src/services/mal/MalSync.ts | 63 +++++++++++++--- src/services/watchedService.ts | 49 +++++++++--- 8 files changed, 258 insertions(+), 38 deletions(-) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 22233377..c0c23833 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -34,7 +34,15 @@ interface SeriesContentProps { onSeasonChange: (season: number) => void; onSelectEpisode: (episode: Episode) => void; groupedEpisodes?: { [seasonNumber: number]: Episode[] }; - metadata?: { poster?: string; id?: string; name?: string }; + metadata?: { + poster?: string; + id?: string; + name?: string; + mal_id?: number; + external_ids?: { + mal_id?: number; + } + }; imdbId?: string; // IMDb ID for Trakt sync } @@ -574,15 +582,31 @@ const SeriesContentComponent: React.FC = ({ // 3. Background Async Operation const showImdbId = imdbId || metadata.id; + const malId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id; + const tmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + + // Calculate dayIndex for same-day releases + let dayIndex = 0; + if (episode.air_date) { + const sameDayEpisodes = episodes + .filter(ep => ep.air_date === episode.air_date) + .sort((a, b) => a.episode_number - b.episode_number); + dayIndex = sameDayEpisodes.findIndex(ep => ep.episode_number === episode.episode_number); + if (dayIndex < 0) dayIndex = 0; + } + try { const result = await watchedService.markEpisodeAsWatched( - showImdbId, - metadata.id, + showImdbId || 'Anime', + metadata.id || '', episode.season_number, episode.episode_number, new Date(), episode.air_date, - metadata?.name + metadata?.name, + malId, + dayIndex, + tmdbId ); // Reload to ensure consistency (e.g. if optimistic update was slightly off or for other effects) diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index bd173777..d5b296a4 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -203,6 +203,21 @@ const AndroidVideoPlayer: React.FC = () => { episodeId: episodeId }); + const currentMalId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id; + const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + + // Calculate dayIndex for same-day releases + const currentDayIndex = useMemo(() => { + if (!releaseDate || !groupedEpisodes) return 0; + // Flatten groupedEpisodes to search for same-day releases + const allEpisodes = Object.values(groupedEpisodes).flat(); + const sameDayEpisodes = allEpisodes + .filter(ep => ep.air_date === releaseDate) + .sort((a, b) => a.episode_number - b.episode_number); + const idx = sameDayEpisodes.findIndex(ep => ep.episode_number === episode); + return idx >= 0 ? idx : 0; + }, [releaseDate, groupedEpisodes, episode]); + const watchProgress = useWatchProgress( id, type, episodeId, playerState.currentTime, @@ -214,7 +229,10 @@ const AndroidVideoPlayer: React.FC = () => { imdbId, season, episode, - releaseDate + releaseDate, + currentMalId, + currentDayIndex, + currentTmdbId ); const gestureControls = usePlayerGestureControls({ diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 342b2f56..6ae37c89 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -222,6 +222,21 @@ const KSPlayerCore: React.FC = () => { isMounted }); + const currentMalId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id; + const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + + // Calculate dayIndex for same-day releases + const currentDayIndex = useMemo(() => { + if (!releaseDate || !groupedEpisodes) return 0; + // Flatten groupedEpisodes to search for same-day releases + const allEpisodes = Object.values(groupedEpisodes).flat() as any[]; + const sameDayEpisodes = allEpisodes + .filter(ep => ep.air_date === releaseDate) + .sort((a, b) => a.episode_number - b.episode_number); + const idx = sameDayEpisodes.findIndex(ep => ep.episode_number === episode); + return idx >= 0 ? idx : 0; + }, [releaseDate, groupedEpisodes, episode]); + const watchProgress = useWatchProgress( id, type, episodeId, currentTime, @@ -233,7 +248,10 @@ const KSPlayerCore: React.FC = () => { imdbId, season, episode, - releaseDate + releaseDate, + currentMalId, + currentDayIndex, + currentTmdbId ); // Gestures diff --git a/src/components/player/hooks/useWatchProgress.ts b/src/components/player/hooks/useWatchProgress.ts index c3b3d69b..7b1f7cef 100644 --- a/src/components/player/hooks/useWatchProgress.ts +++ b/src/components/player/hooks/useWatchProgress.ts @@ -19,7 +19,10 @@ export const useWatchProgress = ( imdbId?: string, season?: number, episode?: number, - releaseDate?: string + releaseDate?: string, + malId?: number, + dayIndex?: number, + tmdbId?: number ) => { const [resumePosition, setResumePosition] = useState(null); const [savedDuration, setSavedDuration] = useState(null); @@ -38,6 +41,20 @@ export const useWatchProgress = ( const seasonRef = useRef(season); const episodeRef = useRef(episode); const releaseDateRef = useRef(releaseDate); + const malIdRef = useRef(malId); + const dayIndexRef = useRef(dayIndex); + const tmdbIdRef = useRef(tmdbId); + + // Sync refs + useEffect(() => { + imdbIdRef.current = imdbId; + seasonRef.current = season; + episodeRef.current = episode; + releaseDateRef.current = releaseDate; + malIdRef.current = malId; + dayIndexRef.current = dayIndex; + tmdbIdRef.current = tmdbId; + }, [imdbId, season, episode, releaseDate, malId, dayIndex, tmdbId]); // Reset scrobble flag when content changes useEffect(() => { @@ -154,6 +171,9 @@ export const useWatchProgress = ( const currentSeason = seasonRef.current; const currentEpisode = episodeRef.current; const currentReleaseDate = releaseDateRef.current; + const currentMalId = malIdRef.current; + const currentDayIndex = dayIndexRef.current; + const currentTmdbId = tmdbIdRef.current; if (type === 'series' && currentImdbId && currentSeason !== undefined && currentEpisode !== undefined) { watchedService.markEpisodeAsWatched( @@ -162,10 +182,14 @@ export const useWatchProgress = ( currentSeason, currentEpisode, new Date(), - currentReleaseDate + currentReleaseDate, + undefined, + currentMalId, + currentDayIndex, + currentTmdbId ); } else if (type === 'movie' && currentImdbId) { - watchedService.markMovieAsWatched(currentImdbId); + watchedService.markMovieAsWatched(currentImdbId, new Date(), currentMalId, currentTmdbId); } } } catch (error) { diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index 09d9fb82..c5924650 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -86,6 +86,13 @@ export interface StreamingContent { [key: string]: any; }; imdb_id?: string; + mal_id?: number; + external_ids?: { + mal_id?: number; + imdb_id?: string; + tmdb_id?: number; + tvdb_id?: number; + }; slug?: string; releaseInfo?: string; traktSource?: 'watchlist' | 'continue-watching' | 'watched'; diff --git a/src/services/mal/ArmSyncService.ts b/src/services/mal/ArmSyncService.ts index 718d5750..a6d283cf 100644 --- a/src/services/mal/ArmSyncService.ts +++ b/src/services/mal/ArmSyncService.ts @@ -31,9 +31,10 @@ export const ArmSyncService = { * * @param imdbId The IMDb ID of the show * @param releaseDateStr The air date of the episode (YYYY-MM-DD) + * @param dayIndex The 0-based index of this episode among others released on the same day (optional) * @returns {Promise} The resolved MAL ID and Episode number */ - resolveByDate: async (imdbId: string, releaseDateStr: string): Promise => { + resolveByDate: async (imdbId: string, releaseDateStr: string, dayIndex?: number): Promise => { try { // Basic validation: ensure date is in YYYY-MM-DD format if (!/^\d{4}-\d{2}-\d{2}/.test(releaseDateStr)) { @@ -59,7 +60,54 @@ export const ArmSyncService = { logger.log(`[ArmSync] Found candidates: ${malIds.join(', ')}`); - // 2. Validate Candidates via Jikan Dates + // 2. Validate Candidates + return await ArmSyncService.resolveFromMalCandidates(malIds, releaseDateStr, dayIndex); + } catch (e) { + logger.error('[ArmSync] Resolution failed:', e); + } + return null; + }, + + /** + * Resolves the correct MyAnimeList ID and Episode Number using ARM (for ID mapping) + * and Jikan (for Air Date matching) using a TMDB ID. + * + * @param tmdbId The TMDB ID of the show + * @param releaseDateStr The air date of the episode (YYYY-MM-DD) + * @param dayIndex The 0-based index of this episode among others released on the same day + * @returns {Promise} The resolved MAL ID and Episode number + */ + resolveByTmdb: async (tmdbId: number, releaseDateStr: string, dayIndex?: number): Promise => { + try { + if (!/^\d{4}-\d{2}-\d{2}/.test(releaseDateStr)) return null; + + logger.log(`[ArmSync] Resolving TMDB ${tmdbId} for date ${releaseDateStr}...`); + + // 1. Fetch Candidates from ARM using TMDB ID + const armRes = await axios.get(`${ARM_BASE}/tmdb`, { + params: { id: tmdbId } + }); + + const malIds = armRes.data + .map(entry => entry.myanimelist) + .filter((id): id is number => !!id); + + if (malIds.length === 0) return null; + + logger.log(`[ArmSync] Found candidates for TMDB ${tmdbId}: ${malIds.join(', ')}`); + + // 2. Validate Candidates + return await ArmSyncService.resolveFromMalCandidates(malIds, releaseDateStr, dayIndex); + } catch (e) { + logger.error('[ArmSync] TMDB resolution failed:', e); + } + return null; + }, + + /** + * Internal helper to find the correct MAL ID from a list of candidates based on date + */ + resolveFromMalCandidates: async (malIds: number[], releaseDateStr: string, dayIndex?: number): Promise => { // Helper to delay (Jikan Rate Limit: 3 req/sec) const delay = (ms: number) => new Promise(res => setTimeout(res, ms)); @@ -96,7 +144,7 @@ export const ArmSyncService = { const epsRes = await axios.get(`${JIKAN_BASE}/anime/${malId}/episodes`); const episodes = epsRes.data.data; - const matchEp = episodes.find((ep: any) => { + const matchingEpisodes = episodes.filter((ep: any) => { if (!ep.aired) return false; try { const epDate = new Date(ep.aired); @@ -113,7 +161,20 @@ export const ArmSyncService = { } }); - if (matchEp) { + if (matchingEpisodes.length > 0) { + // Sort matching episodes by their mal_id to ensure consistent ordering + matchingEpisodes.sort((a: any, b: any) => a.mal_id - b.mal_id); + + let matchEp = matchingEpisodes[0]; + + // If multiple episodes match the same day, use dayIndex to pick the correct one + if (matchingEpisodes.length > 1 && dayIndex !== undefined) { + // If the dayIndex is within bounds, pick it. Otherwise, pick the last one. + const idx = Math.min(dayIndex, matchingEpisodes.length - 1); + matchEp = matchingEpisodes[idx]; + logger.log(`[ArmSync] Disambiguated same-day release using dayIndex ${dayIndex} -> picked Ep #${matchEp.mal_id}`); + } + logger.log(`[ArmSync] Episode resolved: #${matchEp.mal_id} (${matchEp.title})`); return { malId, @@ -126,10 +187,6 @@ export const ArmSyncService = { logger.warn(`[ArmSync] Failed to check candidate ${malId}:`, e); } } - - } catch (e) { - logger.error('[ArmSync] Resolution failed:', e); - } - return null; + return null; } }; diff --git a/src/services/mal/MalSync.ts b/src/services/mal/MalSync.ts index 91350af0..0a61efb7 100644 --- a/src/services/mal/MalSync.ts +++ b/src/services/mal/MalSync.ts @@ -45,7 +45,7 @@ export const MalSync = { * 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, releaseDate?: string): Promise => { + getMalId: async (title: string, type: 'movie' | 'series' = 'series', year?: number, season?: number, imdbId?: string, episode: number = 1, releaseDate?: string, dayIndex?: number, tmdbId?: number): 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 cleanTitle = title.trim(); @@ -64,12 +64,25 @@ export const MalSync = { return cachedId; } - if (isGenericTitle && !imdbId) return null; + if (isGenericTitle && !imdbId && !tmdbId) return null; - // 1. Try ARM + Jikan Sync (Most accurate for perfect season/episode matching) + // 1. Try TMDB-based Resolution (High Accuracy) + if (tmdbId && releaseDate) { + try { + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex); + if (tmdbResult && tmdbResult.malId) { + console.log(`[MalSync] Found TMDB match: ${tmdbId} (${releaseDate}) -> MAL ${tmdbResult.malId}`); + return tmdbResult.malId; + } + } catch (e) { + console.warn('[MalSync] TMDB Sync failed:', e); + } + } + + // 2. Try ARM + Jikan Sync (IMDb fallback) if (imdbId && type === 'series' && releaseDate) { try { - const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate); + const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate, dayIndex); if (armResult && armResult.malId) { console.log(`[MalSync] Found ARM match: ${imdbId} (${releaseDate}) -> MAL ${armResult.malId} Ep ${armResult.episode}`); // Note: ArmSyncService returns the *absolute* episode number for MAL (e.g. 76) @@ -100,6 +113,10 @@ export const MalSync = { if (type === 'series' && season && season > 1) { // Improve search query: "Attack on Titan Season 2" usually works better than just appending searchQuery = `${cleanTitle} Season ${season}`; + } else if (type === 'series' && season === 0) { + // Improve Season 0 (Specials) lookup: "Attack on Titan Specials" or "Attack on Titan OVA" + // We search for both to find the most likely entry + searchQuery = `${cleanTitle} Specials`; } const result = await MalApiService.searchAnime(searchQuery, 10); @@ -109,6 +126,17 @@ export const MalSync = { // Filter by type first if (type === 'movie') { candidates = candidates.filter(r => r.node.media_type === 'movie'); + } else if (season === 0) { + // For Season 0, prioritize specials, ovas, and onas + candidates = candidates.filter(r => r.node.media_type === 'special' || r.node.media_type === 'ova' || r.node.media_type === 'ona'); + if (candidates.length === 0) { + // If no specific special types found, fallback to anything containing "Special" or "OVA" in title + candidates = result.data.filter(r => + r.node.title.toLowerCase().includes('special') || + r.node.title.toLowerCase().includes('ova') || + r.node.title.toLowerCase().includes('ona') + ); + } } 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'); } @@ -150,7 +178,10 @@ export const MalSync = { type: 'movie' | 'series' = 'series', season?: number, imdbId?: string, - releaseDate?: string + releaseDate?: string, + providedMalId?: number, // Optional: skip lookup if already known + dayIndex?: number, // 0-based index of episode in a same-day release batch + tmdbId?: number ) => { try { // Requirement 9 & 10: Respect user settings and safety @@ -161,12 +192,22 @@ export const MalSync = { return; } - let malId: number | null = null; + let malId: number | null = providedMalId || null; let finalEpisodeNumber = episodeNumber; - // Try ARM Sync first to get exact MAL ID and absolute episode number - if (imdbId && type === 'series' && releaseDate) { - const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate); + // Strategy 1: TMDB-based Resolution (High Accuracy for Specials) + if (!malId && tmdbId && releaseDate) { + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex); + if (tmdbResult) { + malId = tmdbResult.malId; + finalEpisodeNumber = tmdbResult.episode; + console.log(`[MalSync] TMDB Resolved: ${animeTitle} -> MAL ${malId} Ep ${finalEpisodeNumber}`); + } + } + + // Strategy 2: IMDb-based Resolution (Fallback) + if (!malId && imdbId && type === 'series' && releaseDate) { + const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate, dayIndex); if (armResult) { malId = armResult.malId; finalEpisodeNumber = armResult.episode; @@ -174,9 +215,9 @@ export const MalSync = { } } - // Fallback to standard lookup if ARM failed or not applicable + // Fallback to standard lookup if ARM/TMDB failed and no ID provided if (!malId) { - malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber, releaseDate); + malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber, releaseDate, dayIndex, tmdbId); } if (!malId) return; diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 5fecc3e6..68dc9477 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -35,7 +35,9 @@ class WatchedService { */ public async markMovieAsWatched( imdbId: string, - watchedAt: Date = new Date() + watchedAt: Date = new Date(), + malId?: number, + tmdbId?: number ): Promise<{ success: boolean; syncedToTrakt: boolean }> { try { logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`); @@ -58,7 +60,11 @@ class WatchedService { 1, 'movie', undefined, - imdbId + imdbId, + undefined, + malId, + undefined, + tmdbId ).catch(err => logger.error('[WatchedService] MAL movie sync failed:', err)); } @@ -94,7 +100,10 @@ class WatchedService { episode: number, watchedAt: Date = new Date(), releaseDate?: string, // Optional release date for precise matching - showTitle?: string + showTitle?: string, + malId?: number, + dayIndex?: number, + tmdbId?: number ): Promise<{ success: boolean; syncedToTrakt: boolean }> { try { logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`); @@ -115,12 +124,31 @@ class WatchedService { // Sync to MAL const malToken = MalAuth.getToken(); - if (malToken && showImdbId) { - // Strategy 1: "Perfect Match" using ARM + Release Date + if (malToken && (showImdbId || malId || tmdbId)) { + // Strategy 0: Direct Match (if malId is provided) let synced = false; - if (releaseDate) { + if (malId) { + await MalSync.scrobbleDirect(malId, episode); + synced = true; + } + + // Strategy 1: TMDB-based Resolution (High Accuracy for Specials) + if (!synced && releaseDate && tmdbId) { try { - const armResult = await ArmSyncService.resolveByDate(showImdbId, releaseDate); + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex); + if (tmdbResult) { + await MalSync.scrobbleDirect(tmdbResult.malId, tmdbResult.episode); + synced = true; + } + } catch (e) { + logger.warn('[WatchedService] TMDB Sync failed, falling back to IMDb:', e); + } + } + + // Strategy 2: IMDb-based Resolution (Fallback) + if (!synced && releaseDate && showImdbId) { + try { + const armResult = await ArmSyncService.resolveByDate(showImdbId, releaseDate, dayIndex); if (armResult) { await MalSync.scrobbleDirect(armResult.malId, armResult.episode); synced = true; @@ -130,7 +158,7 @@ class WatchedService { } } - // Strategy 2: Offline Mapping Fallback + // Strategy 3: Offline Mapping / Search Fallback if (!synced) { MalSync.scrobbleEpisode( showTitle || showImdbId || 'Anime', @@ -139,7 +167,10 @@ class WatchedService { 'series', season, showImdbId, - releaseDate // Pass releaseDate for better matching + releaseDate, + malId, + dayIndex, + tmdbId ).catch(err => logger.error('[WatchedService] MAL sync failed:', err)); } }