diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 643aa0cd..08844610 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -206,7 +206,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe } } else { if (item.type === 'movie') { - watchedService.unmarkMovieAsWatched(item.id, item.imdb_id ?? undefined); + watchedService.unmarkMovieAsWatched(item.id, undefined, undefined, item.name, item.imdb_id ?? undefined); } else { // Unmarking a series from the top level is tricky as we don't know the exact episodes. // For safety and consistency with old behavior, we just clear the legacy flag. diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index b2eb5c78..bdc5984b 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -650,12 +650,30 @@ 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.unmarkEpisodeAsWatched( - showImdbId, - metadata.id, + showImdbId || '', + metadata.id || '', episode.season_number, - episode.episode_number + episode.episode_number, + episode.air_date, + metadata?.name, + malId, + dayIndex, + tmdbId ); loadEpisodesProgress(); // Sync with source of truth @@ -768,12 +786,23 @@ 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; + + const lastEp = Math.max(...episodeNumbers); + const lastEpisodeData = seasonEpisodes.find(e => e.episode_number === lastEp); + try { const result = await watchedService.unmarkSeasonAsWatched( - showImdbId, - metadata.id, + showImdbId || '', + metadata.id || '', currentSeason, - episodeNumbers + episodeNumbers, + lastEpisodeData?.air_date, + metadata?.name, + malId, + 0, // dayIndex (assuming 0 for season batch unmarking) + tmdbId ); // Re-sync diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 99a346c6..5933a1ef 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -271,6 +271,8 @@ const AndroidVideoPlayer: React.FC = () => { const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId); + const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + const { segments: skipIntervals, outroSegment } = useSkipSegments({ imdbId: resolvedImdbId || (id?.startsWith('tt') ? id : undefined), type, @@ -278,6 +280,7 @@ const AndroidVideoPlayer: React.FC = () => { episode, malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, + tmdbId: currentTmdbId, enabled: settings.skipIntroEnabled }); diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 4fc0a9e2..dc745f46 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -214,6 +214,8 @@ const KSPlayerCore: React.FC = () => { episodeId }); + const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id; + const { segments: skipIntervals, outroSegment } = useSkipSegments({ imdbId: imdbId || (id?.startsWith('tt') ? id : undefined), type, @@ -221,6 +223,7 @@ const KSPlayerCore: React.FC = () => { episode, malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, + tmdbId: currentTmdbId, enabled: settings.skipIntroEnabled }); @@ -243,7 +246,6 @@ const KSPlayerCore: React.FC = () => { }); 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(() => { diff --git a/src/components/player/hooks/useSkipSegments.ts b/src/components/player/hooks/useSkipSegments.ts index a5e868fe..61f6fb23 100644 --- a/src/components/player/hooks/useSkipSegments.ts +++ b/src/components/player/hooks/useSkipSegments.ts @@ -10,6 +10,7 @@ interface UseSkipSegmentsProps { malId?: string; kitsuId?: string; releaseDate?: string; + tmdbId?: number; enabled: boolean; } @@ -21,6 +22,7 @@ export const useSkipSegments = ({ malId, kitsuId, releaseDate, + tmdbId, enabled }: UseSkipSegmentsProps) => { const [segments, setSegments] = useState([]); @@ -29,9 +31,9 @@ export const useSkipSegments = ({ const lastKeyRef = useRef(''); useEffect(() => { - const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${releaseDate}`; + const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${releaseDate}-${tmdbId}`; - if (!enabled || type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) { + if (!enabled || type !== 'series' || (!imdbId && !malId && !kitsuId && !tmdbId) || !season || !episode) { setSegments([]); setIsLoading(false); fetchedRef.current = false; @@ -55,7 +57,7 @@ export const useSkipSegments = ({ const fetchSegments = async () => { try { - const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, releaseDate); + const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, releaseDate, tmdbId); // Ignore stale responses from old requests. if (cancelled || lastKeyRef.current !== key) return; @@ -78,7 +80,7 @@ export const useSkipSegments = ({ return () => { cancelled = true; }; - }, [imdbId, type, season, episode, malId, kitsuId, releaseDate, enabled]); + }, [imdbId, type, season, episode, malId, kitsuId, releaseDate, tmdbId, enabled]); const getActiveSegment = (currentTime: number) => { return segments.find( diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index e9e4dcea..d8291a74 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -962,6 +962,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat name: localized.title || finalMetadata.name, description: localized.overview || finalMetadata.description, movieDetails: movieDetailsObj, + tmdbId: finalTmdbId, ...(productionInfo.length > 0 && { networks: productionInfo }), }; } @@ -999,6 +1000,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat name: localized.name || finalMetadata.name, description: localized.overview || finalMetadata.description, tvDetails, + tmdbId: finalTmdbId, ...(productionInfo.length > 0 && { networks: productionInfo }), }; } diff --git a/src/services/introService.ts b/src/services/introService.ts index db6c949d..cf71eef6 100644 --- a/src/services/introService.ts +++ b/src/services/introService.ts @@ -306,7 +306,8 @@ export async function getSkipTimes( episode: number, malId?: string, kitsuId?: string, - releaseDate?: string + releaseDate?: string, + tmdbId?: number ): Promise { // 1. Try IntroDB (TV Shows) first if (imdbId) { @@ -320,7 +321,21 @@ export async function getSkipTimes( let finalMalId = malId; let finalEpisode = episode; - // If we have IMDb ID and Release Date, try ArmSyncService to resolve exact MAL ID and Episode + // Priority 1: TMDB-based Resolution (Highest Accuracy) + if (!finalMalId && tmdbId && releaseDate) { + try { + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate); + if (tmdbResult) { + finalMalId = tmdbResult.malId.toString(); + finalEpisode = tmdbResult.episode; + logger.log(`[IntroService] TMDB resolved: MAL ${finalMalId} Ep ${finalEpisode}`); + } + } catch (e) { + logger.warn('[IntroService] TMDB resolve failed', e); + } + } + + // Priority 2: IMDb-based ARM Sync (Fallback) if (!finalMalId && imdbId && releaseDate) { try { const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate); diff --git a/src/services/mal/MalSync.ts b/src/services/mal/MalSync.ts index d89873fd..6dbeba8e 100644 --- a/src/services/mal/MalSync.ts +++ b/src/services/mal/MalSync.ts @@ -282,6 +282,71 @@ export const MalSync = { } }, + unscrobbleEpisode: async ( + animeTitle: string, + episodeNumber: number, + type: 'movie' | 'series' = 'series', + season?: number, + imdbId?: string, + releaseDate?: string, + providedMalId?: number, + dayIndex?: number, + tmdbId?: number + ) => { + try { + if (!MalAuth.isAuthenticated()) return; + + let malId: number | null = providedMalId || null; + let finalEpisodeNumber = episodeNumber; + + // Resolve ID using same strategies as scrobbling + if (!malId && tmdbId && releaseDate) { + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex); + if (tmdbResult) { + malId = tmdbResult.malId; + finalEpisodeNumber = tmdbResult.episode; + } + } + + if (!malId && imdbId && type === 'series' && releaseDate) { + const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate, dayIndex); + if (armResult) { + malId = armResult.malId; + finalEpisodeNumber = armResult.episode; + } + } + + if (!malId) { + malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber, releaseDate, dayIndex, tmdbId); + } + + if (!malId) return; + + // Get current count + const currentInfo = await MalApiService.getMyListStatus(malId); + if (!currentInfo.my_list_status) return; + + // Decrement logic: Only if the episode we are unmarking is the LAST one watched or current + const currentlyWatched = currentInfo.my_list_status.num_episodes_watched; + if (finalEpisodeNumber === currentlyWatched) { + const newCount = Math.max(0, finalEpisodeNumber - 1); + let newStatus = currentInfo.my_list_status.status; + + // If we unmark everything, maybe move back to 'plan_to_watch' or keep 'watching' + if (newCount === 0 && newStatus === 'watching') { + // Optional: Move back to plan to watch if desired + } else if (newStatus === 'completed') { + newStatus = 'watching'; + } + + await MalApiService.updateStatus(malId, newStatus, newCount); + console.log(`[MalSync] Unscrobbled MAL ID ${malId} to Ep ${newCount}`); + } + } catch (e) { + console.error('[MalSync] Unscrobble failed:', e); + } + }, + /** * Direct scrobble with known MAL ID and Episode * Used when ArmSync has already resolved the exact details. diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 07aa1ac2..43608f64 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -6,6 +6,7 @@ import { logger } from '../utils/logger'; import { MalSync } from './mal/MalSync'; import { MalAuth } from './mal/MalAuth'; import { ArmSyncService } from './mal/ArmSyncService'; +import { MalApiService } from './mal/MalApi'; export interface LocalWatchedItem { content_id: string; @@ -577,10 +578,16 @@ class WatchedService { /** * Unmark a movie as watched (remove from history). * @param imdbId - The primary content ID (may be a provider ID like "kitsu:123") + * @param malId - Optional MAL ID + * @param tmdbId - Optional TMDB ID + * @param title - Optional title * @param fallbackImdbId - The resolved IMDb ID from metadata (used when imdbId isn't IMDb format) */ public async unmarkMovieAsWatched( imdbId: string, + malId?: number, + tmdbId?: number, + title?: string, fallbackImdbId?: string ): Promise<{ success: boolean; syncedToTrakt: boolean }> { try { @@ -594,6 +601,21 @@ class WatchedService { logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`); } + // Sync to MAL + if (MalAuth.isAuthenticated()) { + MalSync.unscrobbleEpisode( + title || 'Movie', + 1, + 'movie', + undefined, + imdbId, + undefined, + malId, + undefined, + tmdbId + ).catch(err => logger.error('[WatchedService] MAL movie unsync failed:', err)); + } + // Simkl Unmark — try both IDs const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { @@ -627,7 +649,12 @@ class WatchedService { showImdbId: string, showId: string, season: number, - episode: number + episode: number, + releaseDate?: string, + showTitle?: string, + malId?: number, + dayIndex?: number, + tmdbId?: number ): Promise<{ success: boolean; syncedToTrakt: boolean }> { try { logger.log(`[WatchedService] Unmarking episode as watched: ${showImdbId} S${season}E${episode}`); @@ -647,6 +674,21 @@ class WatchedService { logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`); } + // Sync to MAL + if (MalAuth.isAuthenticated()) { + MalSync.unscrobbleEpisode( + showTitle || 'Anime', + episode, + 'series', + season, + showImdbId, + releaseDate, + malId, + dayIndex, + tmdbId + ).catch(err => logger.error('[WatchedService] MAL unsync failed:', err)); + } + // Simkl Unmark — use best available ID const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { @@ -688,7 +730,12 @@ class WatchedService { showImdbId: string, showId: string, season: number, - episodeNumbers: number[] + episodeNumbers: number[], + releaseDate?: string, + showTitle?: string, + malId?: number, + dayIndex?: number, + tmdbId?: number ): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> { try { logger.log(`[WatchedService] Unmarking season ${season} as watched for ${showImdbId}`); @@ -708,6 +755,79 @@ class WatchedService { logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`); } + // Sync to MAL (Unscrobble the latest episode in this season ONLY if it's the one we're currently on) + if (MalAuth.isAuthenticated() && episodeNumbers.length > 0) { + const maxEpisodeInSeason = Math.max(...episodeNumbers); + + const resolveAndUnscrobble = async () => { + try { + // Use the robust resolution logic from MalSync.unscrobbleEpisode + // to find the ACTUAL malId and absolute episode number + let finalMalId = malId; + let resolvedEpisode = maxEpisodeInSeason; + + // 1. Try TMDB Resolution + if (!finalMalId && tmdbId && releaseDate) { + const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex); + if (tmdbResult) { + finalMalId = tmdbResult.malId; + resolvedEpisode = tmdbResult.episode; + } + } + + // 2. Try IMDb/ARM Fallback + if (!finalMalId && showImdbId && releaseDate) { + const armResult = await ArmSyncService.resolveByDate(showImdbId, releaseDate, dayIndex); + if (armResult) { + finalMalId = armResult.malId; + resolvedEpisode = armResult.episode; + } + } + + // 3. Last resort: Standard lookup + if (!finalMalId) { + finalMalId = (await MalSync.getMalId( + showTitle || 'Anime', + 'series', + undefined, + season, + showImdbId, + maxEpisodeInSeason, + releaseDate, + dayIndex, + tmdbId + )) || undefined; + } + + if (finalMalId) { + const currentInfo = await MalApiService.getMyListStatus(finalMalId); + const currentlyWatched = currentInfo.my_list_status?.num_episodes_watched || 0; + + // Only unscrobble if the season's end matches our current progress + if (currentlyWatched === resolvedEpisode) { + // Calculate the episode count BEFORE this season started + const minEpisodeInSeason = Math.min(...episodeNumbers); + const newCount = Math.max(0, minEpisodeInSeason - 1); + + let newStatus: any = currentInfo.my_list_status?.status || 'watching'; + if (newCount === 0 && newStatus === 'watching') { + // Optional: could move to plan_to_watch + } else if (newStatus === 'completed') { + newStatus = 'watching'; + } + + await MalApiService.updateStatus(finalMalId, newStatus, newCount); + logger.log(`[WatchedService] Unmarked season: MAL ID ${finalMalId} reverted to Ep ${newCount}`); + } + } + } catch (e) { + logger.error('[WatchedService] MAL season unsync resolution failed:', e); + } + }; + + resolveAndUnscrobble(); + } + // Sync to Simkl — use best available ID const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) {