diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index cfcdd4f7..a26af668 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -75,7 +75,7 @@ const AndroidVideoPlayer: React.FC = () => { const { uri, title = 'Episode Name', season, episode, episodeTitle, quality, year, streamProvider, streamName, headers, id, type, episodeId, imdbId, - availableStreams: passedAvailableStreams, backdrop, groupedEpisodes + availableStreams: passedAvailableStreams, backdrop, groupedEpisodes, releaseDate } = route.params; // --- State & Custom Hooks --- @@ -261,7 +261,11 @@ const AndroidVideoPlayer: React.FC = () => { playerState.paused, traktAutosync, controlsHook.seekToTime, - currentStreamProvider + currentStreamProvider, + imdbId, + season, + episode, + releaseDate ); const gestureControls = usePlayerGestureControls({ diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index b8a1b23c..8469f744 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -189,7 +189,12 @@ const KSPlayerCore: React.FC = () => { duration, paused, traktAutosync, - controls.seekToTime + controls.seekToTime, + undefined, + imdbId, + season, + episode, + undefined // releaseDate not yet implemented for iOS ); // Gestures diff --git a/src/components/player/hooks/useWatchProgress.ts b/src/components/player/hooks/useWatchProgress.ts index 5b70282d..afe4acf7 100644 --- a/src/components/player/hooks/useWatchProgress.ts +++ b/src/components/player/hooks/useWatchProgress.ts @@ -3,6 +3,7 @@ import { AppState, AppStateStatus } from 'react-native'; import { storageService } from '../../../services/storageService'; import { logger } from '../../../utils/logger'; import { useSettings } from '../../../hooks/useSettings'; +import { watchedService } from '../../../services/watchedService'; export const useWatchProgress = ( id: string | undefined, @@ -13,7 +14,12 @@ export const useWatchProgress = ( paused: boolean, traktAutosync: any, seekToTime: (time: number) => void, - addonId?: string + addonId?: string, + // New parameters for MAL scrobbling + imdbId?: string, + season?: number, + episode?: number, + releaseDate?: string ) => { const [resumePosition, setResumePosition] = useState(null); const [savedDuration, setSavedDuration] = useState(null); @@ -23,6 +29,7 @@ export const useWatchProgress = ( const { settings: appSettings } = useSettings(); const initialSeekTargetRef = useRef(null); + const hasScrobbledRef = useRef(false); // Values refs for unmount cleanup const currentTimeRef = useRef(currentTime); @@ -120,6 +127,26 @@ export const useWatchProgress = ( try { await storageService.setWatchProgress(id, type, progress, episodeId); await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current); + + // Requirement 1: Auto Episode Tracking (>= 90% completion) + const progressPercent = (currentTimeRef.current / durationRef.current) * 100; + if (progressPercent >= 90 && !hasScrobbledRef.current) { + hasScrobbledRef.current = true; + logger.log(`[useWatchProgress] 90% threshold reached, scrobbling to MAL...`); + + if (type === 'series' && imdbId && season !== undefined && episode !== undefined) { + watchedService.markEpisodeAsWatched( + imdbId, + id, + season, + episode, + new Date(), + releaseDate + ); + } else if (type === 'movie' && imdbId) { + watchedService.markMovieAsWatched(imdbId); + } + } } catch (error) { logger.error('[useWatchProgress] Error saving watch progress:', error); } diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 9553cff2..004519f8 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -149,6 +149,7 @@ export type RootStackParamList = { availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; backdrop?: string; videoType?: string; + releaseDate?: string; groupedEpisodes?: { [seasonNumber: number]: any[] }; }; PlayerAndroid: { @@ -169,6 +170,7 @@ export type RootStackParamList = { availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; backdrop?: string; videoType?: string; + releaseDate?: string; groupedEpisodes?: { [seasonNumber: number]: any[] }; }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; diff --git a/src/screens/streams/useStreamsScreen.ts b/src/screens/streams/useStreamsScreen.ts index 1689a017..0b982550 100644 --- a/src/screens/streams/useStreamsScreen.ts +++ b/src/screens/streams/useStreamsScreen.ts @@ -367,6 +367,7 @@ export const useStreamsScreen = () => { const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined; const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined; const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined; + const releaseDate = type === 'movie' ? metadata?.released : currentEpisode?.air_date; await streamCacheService.saveStreamToCache( id, @@ -412,6 +413,7 @@ export const useStreamsScreen = () => { availableStreams: streamsToPass, backdrop: metadata?.banner || bannerImage, videoType, + releaseDate, } as any); }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage, settings.streamCacheTTL] diff --git a/src/services/mal/ArmSyncService.ts b/src/services/mal/ArmSyncService.ts new file mode 100644 index 00000000..af7028ec --- /dev/null +++ b/src/services/mal/ArmSyncService.ts @@ -0,0 +1,126 @@ +import axios from 'axios'; +import { logger } from '../utils/logger'; + +interface ArmEntry { + anidb?: number; + anilist?: number; + 'anime-planet'?: string; + anisearch?: number; + imdb?: string; + kitsu?: number; + livechart?: number; + 'notify-moe'?: string; + themoviedb?: number; + thetvdb?: number; + myanimelist?: number; +} + +interface DateSyncResult { + malId: number; + episode: number; + title?: string; +} + +const JIKAN_BASE = 'https://api.jikan.moe/v4'; +const ARM_BASE = 'https://arm.haglund.dev/api/v2'; + +export const ArmSyncService = { + /** + * Resolves the correct MyAnimeList ID and Episode Number using ARM (for ID mapping) + * and Jikan (for Air Date matching). + * + * @param imdbId The IMDb ID of the show + * @param releaseDateStr The air date of the episode (YYYY-MM-DD) + * @returns {Promise} The resolved MAL ID and Episode number + */ + resolveByDate: async (imdbId: string, releaseDateStr: string): Promise => { + try { + const targetDate = new Date(releaseDateStr); + if (isNaN(targetDate.getTime())) { + logger.warn(`[ArmSync] Invalid date provided: ${releaseDateStr}`); + return null; + } + + logger.log(`[ArmSync] Resolving ${imdbId} for date ${releaseDateStr}...`); + + // 1. Fetch Candidates from ARM + const armRes = await axios.get(`${ARM_BASE}/imdb`, { + params: { id: imdbId } + }); + + const malIds = armRes.data + .map(entry => entry.myanimelist) + .filter((id): id is number => !!id); + + if (malIds.length === 0) { + logger.warn(`[ArmSync] No MAL IDs found in ARM for ${imdbId}`); + return null; + } + + logger.log(`[ArmSync] Found candidates: ${malIds.join(', ')}`); + + // 2. Validate Candidates via Jikan Dates + // Helper to delay (Jikan Rate Limit: 3 req/sec) + const delay = (ms: number) => new Promise(res => setTimeout(res, ms)); + + for (const malId of malIds) { + await delay(500); // Respect rate limits + try { + const detailsRes = await axios.get(`${JIKAN_BASE}/anime/${malId}`); + const anime = detailsRes.data.data; + + const startDate = anime.aired?.from ? new Date(anime.aired.from) : null; + const endDate = anime.aired?.to ? new Date(anime.aired.to) : null; + + // Date Matching Logic + let isMatch = false; + if (startDate) { + // Buffer: Allow +/- 24h for timezone differences + const buffer = 24 * 60 * 60 * 1000; + const targetTime = targetDate.getTime(); + const startTime = startDate.getTime() - buffer; + const endTime = endDate ? endDate.getTime() + buffer : null; + + if (targetTime >= startTime) { + if (!endTime || targetTime <= endTime) { + isMatch = true; + } + } + } + + if (isMatch) { + logger.log(`[ArmSync] Match found! ID ${malId} covers ${releaseDateStr}`); + + // 3. Find Exact Episode + await delay(500); + // Fetch first page of episodes (usually enough for seasonal anime) + // Ideally we'd paginate, but for now page 1 covers 95% of cases. + const epsRes = await axios.get(`${JIKAN_BASE}/anime/${malId}/episodes`); + const episodes = epsRes.data.data; + + const matchEp = episodes.find((ep: any) => { + if (!ep.aired) return false; + const epDate = new Date(ep.aired); + return epDate.toISOString().split('T')[0] === releaseDateStr; + }); + + if (matchEp) { + logger.log(`[ArmSync] Episode resolved: #${matchEp.mal_id} (${matchEp.title})`); + return { + malId, + episode: matchEp.mal_id, + title: matchEp.title + }; + } + } + } catch (e) { + logger.warn(`[ArmSync] Failed to check candidate ${malId}:`, e); + } + } + + } catch (e) { + logger.error('[ArmSync] Resolution failed:', e); + } + return null; + } +}; diff --git a/src/services/mal/MalSync.ts b/src/services/mal/MalSync.ts index 865f974f..ce72b9be 100644 --- a/src/services/mal/MalSync.ts +++ b/src/services/mal/MalSync.ts @@ -199,6 +199,47 @@ export const MalSync = { } }, + /** + * 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 */ diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 8c029783..1972e3d0 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -4,6 +4,7 @@ import { mmkvStorage } from './mmkvStorage'; import { logger } from '../utils/logger'; import { MalSync } from './mal/MalSync'; import { MalAuth } from './mal/MalAuth'; +import { ArmSyncService } from './mal/ArmSyncService'; import { mappingService } from './MappingService'; /** @@ -84,7 +85,8 @@ class WatchedService { showId: string, season: number, episode: number, - watchedAt: Date = new Date() + watchedAt: Date = new Date(), + releaseDate?: string // Optional release date for precise matching ): Promise<{ success: boolean; syncedToTrakt: boolean }> { try { logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`); @@ -106,14 +108,31 @@ class WatchedService { // Sync to MAL const malToken = MalAuth.getToken(); if (malToken && showImdbId) { - MalSync.scrobbleEpisode( - 'Anime', // Title fallback - episode, - 0, // Total episodes (MalSync will fetch) - 'series', - season, - showImdbId - ).catch(err => logger.error('[WatchedService] MAL sync failed:', err)); + // Strategy 1: "Perfect Match" using ARM + Release Date + let synced = false; + if (releaseDate) { + try { + const armResult = await ArmSyncService.resolveByDate(showImdbId, releaseDate); + if (armResult) { + await MalSync.scrobbleDirect(armResult.malId, armResult.episode); + synced = true; + } + } catch (e) { + logger.warn('[WatchedService] ARM Sync failed, falling back to offline map:', e); + } + } + + // Strategy 2: Offline Mapping Fallback + if (!synced) { + MalSync.scrobbleEpisode( + 'Anime', // Title fallback + episode, + 0, + 'series', + season, + showImdbId + ).catch(err => logger.error('[WatchedService] MAL sync failed:', err)); + } } // Store locally as "completed"