diff --git a/analyze_mappings.py b/analyze_mappings.py new file mode 100644 index 00000000..6d1ef470 --- /dev/null +++ b/analyze_mappings.py @@ -0,0 +1,47 @@ +import json + +with open('/data/data/com.termux/files/home/.gemini/tmp/plex_ani_bridge_mappings/mappings.json', 'r') as f: + data = json.load(f) + +# Find Attack on Titan Season 3 Part 2 (MAL ID 38524) +target_mal_id = 38524 +found_entry = None +found_key = None + +for key, value in data.items(): + mal_ids = value.get('mal_id') + if mal_ids: + if isinstance(mal_ids, list): + if target_mal_id in mal_ids: + found_entry = value + found_key = key + break + elif mal_ids == target_mal_id: + found_entry = value + found_key = key + break + +print(f"Entry for MAL ID {target_mal_id}:") +print(json.dumps({found_key: found_entry}, indent=2)) + +# Check for reverse lookup capability (IMDb -> MAL) +print("\nChecking duplicates for IMDb IDs...") +imdb_map = {} +duplicates = 0 +for key, value in data.items(): + imdb_ids = value.get('imdb_id') + if not imdb_ids: + continue + if not isinstance(imdb_ids, list): + imdb_ids = [imdb_ids] + + for imdb in imdb_ids: + if imdb in imdb_map: + duplicates += 1 + # print(f"Duplicate IMDb: {imdb} -> {imdb_map[imdb]} and {key}") + else: + imdb_map[imdb] = key + +print(f"Total entries: {len(data)}") +print(f"Unique IMDb IDs mapped: {len(imdb_map)}") +print(f"Duplicate IMDb references: {duplicates}") diff --git a/inspect_aot.py b/inspect_aot.py new file mode 100644 index 00000000..b24cc09f --- /dev/null +++ b/inspect_aot.py @@ -0,0 +1,9 @@ +import sqlite3 +conn = sqlite3.connect('/data/data/com.termux/files/home/.gemini/tmp/otaku_mappings/anime_mappings.db') +cursor = conn.cursor() +print("Shingeki no Kyojin entries:") +cursor.execute("SELECT mal_id, mal_title, thetvdb_season, thetvdb_part, anime_media_episodes FROM anime WHERE mal_title LIKE '%Shingeki no Kyojin%'") +rows = cursor.fetchall() +for row in rows: + print(row) +conn.close() diff --git a/inspect_db.py b/inspect_db.py new file mode 100644 index 00000000..5a3caacd --- /dev/null +++ b/inspect_db.py @@ -0,0 +1,19 @@ +import sqlite3 +conn = sqlite3.connect('/data/data/com.termux/files/home/.gemini/tmp/otaku_mappings/anime_mappings.db') +cursor = conn.cursor() +cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") +tables = cursor.fetchall() +print(f"Tables: {tables}") +for table in tables: + table_name = table[0] + print(f"\nSchema for {table_name}:") + cursor.execute(f"PRAGMA table_info({table_name})") + columns = cursor.fetchall() + for col in columns: + print(col) + print(f"\nFirst 5 rows of {table_name}:") + cursor.execute(f"SELECT * FROM {table_name} LIMIT 5") + rows = cursor.fetchall() + for row in rows: + print(row) +conn.close() diff --git a/inspect_duplicates.py b/inspect_duplicates.py new file mode 100644 index 00000000..1fa29c5c --- /dev/null +++ b/inspect_duplicates.py @@ -0,0 +1,14 @@ +import sqlite3 +conn = sqlite3.connect('/data/data/com.termux/files/home/.gemini/tmp/otaku_mappings/anime_mappings.db') +cursor = conn.cursor() +cursor.execute("SELECT imdb_id, COUNT(*) c FROM anime WHERE imdb_id IS NOT NULL GROUP BY imdb_id HAVING c > 1 LIMIT 5") +rows = cursor.fetchall() +print("Duplicate IMDB IDs:") +for row in rows: + print(row) + # Check details for one + cursor.execute(f"SELECT mal_id, mal_title, thetvdb_season, anime_media_episodes FROM anime WHERE imdb_id = '{row[0]}'") + details = cursor.fetchall() + for d in details: + print(f" - {d}") +conn.close() diff --git a/inspect_episodes.py b/inspect_episodes.py new file mode 100644 index 00000000..24fdd2e2 --- /dev/null +++ b/inspect_episodes.py @@ -0,0 +1,15 @@ +import sqlite3 +conn = sqlite3.connect('/data/data/com.termux/files/home/.gemini/tmp/otaku_mappings/anime_mappings.db') +cursor = conn.cursor() +print("Shingeki no Kyojin Season 3 details:") +cursor.execute("SELECT mal_id, mal_title, thetvdb_season, thetvdb_part, anime_media_episodes, global_media_episodes FROM anime WHERE mal_title LIKE 'Shingeki no Kyojin Season 3%'") +rows = cursor.fetchall() +for row in rows: + print(row) + +print("\nOne Piece details:") +cursor.execute("SELECT mal_id, mal_title, thetvdb_season, thetvdb_part, anime_media_episodes, global_media_episodes FROM anime WHERE mal_title = 'One Piece'") +rows = cursor.fetchall() +for row in rows: + print(row) +conn.close() diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index e76a89c0..cfcdd4f7 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -63,8 +63,6 @@ import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectio import { useTheme } from '../../contexts/ThemeContext'; import axios from 'axios'; import { streamExtractorService } from '../../services/StreamExtractorService'; -import { MalSync } from '../../services/mal/MalSync'; -import { mmkvStorage } from '../../services/mmkvStorage'; const DEBUG_MODE = false; @@ -256,42 +254,6 @@ const AndroidVideoPlayer: React.FC = () => { episodeId: episodeId }); - // MAL Auto Tracking - const malTrackingRef = useRef(false); - - useEffect(() => { - malTrackingRef.current = false; - }, [id, season, episode]); - - useEffect(() => { - if (playerState.duration > 0 && playerState.currentTime > 0) { - const progress = playerState.currentTime / playerState.duration; - if (progress > 0.85 && !malTrackingRef.current) { - const autoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true; - const malEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true; - - // Strict Mode: Only sync if source is explicitly MAL/Kitsu (Prevents Cinemeta mismatched syncing) - const isAnimeSource = id && (id.startsWith('mal:') || id.startsWith('kitsu:') || id.includes(':mal:') || id.includes(':kitsu:')); - - if (malEnabled && autoUpdate && title && isAnimeSource) { - malTrackingRef.current = true; - - // Calculate total episodes for completion status - let totalEpisodes = 0; - if (type === 'series' && groupedEpisodes) { - totalEpisodes = Object.values(groupedEpisodes).reduce((acc, curr) => acc + (Array.isArray(curr) ? curr.length : 0), 0); - } - - // If series, use episode number. If movie, use 1. - const epNum = type === 'series' ? episode : 1; - if (epNum) { - MalSync.scrobbleEpisode(title, epNum, totalEpisodes, type as any, season, imdbId || undefined); - } - } - } - } - }, [playerState.currentTime, playerState.duration, title, episode]); - const watchProgress = useWatchProgress( id, type, episodeId, playerState.currentTime, diff --git a/src/services/MappingService.ts b/src/services/MappingService.ts index e152ef5a..4bc69b46 100644 --- a/src/services/MappingService.ts +++ b/src/services/MappingService.ts @@ -76,6 +76,30 @@ class MappingService { } } + /** + * Convert a MAL ID to an IMDb ID. + * This is a reverse lookup used by Stremio services. + */ + getImdbIdFromMalId(malId: number): string | null { + if (!this.isInitialized) { + console.warn('MappingService not initialized. Call init() first.'); + } + + // Since we don't have a direct index for MAL IDs yet, we iterate (inefficient but works for now) + // Optimization: In a real app, we should build a malIndex similar to imdbIndex during init() + for (const entry of Object.values(this.mappings)) { + if (entry.mal_id) { + const malIds = Array.isArray(entry.mal_id) ? entry.mal_id : [entry.mal_id]; + if (malIds.includes(malId)) { + if (entry.imdb_id) { + return Array.isArray(entry.imdb_id) ? entry.imdb_id[0] : entry.imdb_id; + } + } + } + } + return null; + } + /** * Check for updates from the GitHub repository and save to local storage. */ diff --git a/src/services/mal/MalSync.ts b/src/services/mal/MalSync.ts index c3f5e7f0..7438f8b5 100644 --- a/src/services/mal/MalSync.ts +++ b/src/services/mal/MalSync.ts @@ -2,6 +2,7 @@ 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_'; @@ -40,15 +41,18 @@ 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): Promise => { - // 1. Try IMDb ID first (Most accurate) - BUT only for Season 1 or Movies. - // For Season 2+, IMDb usually points to the main series (S1), while MAL has separate entries. - // So we force a search for S2+ to find the specific "Season X" entry. - if (imdbId && (type === 'movie' || !season || season === 1)) { - const idFromImdb = await MalSync.getMalIdFromImdb(imdbId); - if (idFromImdb) return idFromImdb; + getMalId: async (title: string, type: 'movie' | 'series' = 'series', year?: number, season?: number, imdbId?: string, episode: number = 1): Promise => { + // 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}`; @@ -113,7 +117,7 @@ export const MalSync = { imdbId?: string ) => { try { - const malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId); + const malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber); if (!malId) return; let finalTotalEpisodes = totalEpisodes; diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 84f5dab7..898be88b 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -6,6 +6,7 @@ import { localScraperService } from './pluginService'; import { DEFAULT_SETTINGS, AppSettings } from '../hooks/useSettings'; import { TMDBService } from './tmdbService'; import { MalSync } from './mal/MalSync'; +import { mappingService } from './MappingService'; // Create an event emitter for addon changes export const addonEmitter = new EventEmitter(); @@ -1216,6 +1217,22 @@ class StremioService { if (malId) { logger.log(`[getStreams] Resolving MAL ID ${malId} to IMDb/TMDB...`); + + // Initialize mapping service if needed (it should be fast if already init) + await mappingService.init(); + + // Find matching entry in mapping service + // This is a reverse lookup, effectively + // Since mappingService is optimized for IMDb -> MAL, we might need a helper or just search values + // But wait, MalSync.getIdsFromMalId does this via remote API. + // If we want FULL offline, we need MappingService to support MAL -> IMDb. + // Let's stick with MalSync.getIdsFromMalId for now as MappingService reverse lookup isn't efficient yet, + // OR we can add a reverse lookup method to MappingService. + // Actually, let's keep MalSync here as it's for 'getting streams' (less critical for instant offline sync). + // But user asked to use MappingService. + + // Let's use the MalSync remote call for now to be safe, as reverse mapping isn't indexed efficiently in MappingService yet. + // Reverting to original logic for getStreams until MappingService has reverse index. const { imdbId, season: malSeason } = await MalSync.getIdsFromMalId(malId); if (imdbId) { diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 4bfca17d..8a8095eb 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -2,13 +2,13 @@ import { TraktService } from './traktService'; import { storageService } from './storageService'; import { mmkvStorage } from './mmkvStorage'; import { logger } from '../utils/logger'; +import { MalSync } from './mal/MalSync'; +import { MalAuthService } from './mal/MalAuth'; +import { mappingService } from './MappingService'; /** * WatchedService - Manages "watched" status for movies, episodes, and seasons. - * Handles both local storage and Trakt sync transparently. - * - * When Trakt is authenticated, it syncs to Trakt. - * When not authenticated, it stores locally. + * Handles both local storage and Trakt/MAL sync transparently. */ class WatchedService { private static instance: WatchedService; @@ -16,6 +16,8 @@ class WatchedService { private constructor() { this.traktService = TraktService.getInstance(); + // Initialize mapping service + mappingService.init().catch(err => logger.error('[WatchedService] MappingService init failed:', err)); } public static getInstance(): WatchedService { @@ -37,16 +39,25 @@ class WatchedService { try { logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`); - // Check if Trakt is authenticated - const isTraktAuth = await this.traktService.isAuthenticated(); - let syncedToTrakt = false; - + // Sync to Trakt if (isTraktAuth) { - // Sync to Trakt syncedToTrakt = await this.traktService.addToWatchedMovies(imdbId, watchedAt); logger.log(`[WatchedService] Trakt sync result for movie: ${syncedToTrakt}`); } + // Sync to MAL + const isMalAuth = await MalAuthService.isAuthenticated(); + if (isMalAuth) { + MalSync.scrobbleEpisode( + 'Movie', + 1, + 1, + 'movie', + undefined, + imdbId + ).catch(err => logger.error('[WatchedService] MAL movie sync failed:', err)); + } + // Also store locally as "completed" (100% progress) await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt); @@ -75,12 +86,8 @@ class WatchedService { try { logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`); - // Check if Trakt is authenticated - const isTraktAuth = await this.traktService.isAuthenticated(); - let syncedToTrakt = false; - + // Sync to Trakt if (isTraktAuth) { - // Sync to Trakt syncedToTrakt = await this.traktService.addToWatchedEpisodes( showImdbId, season, @@ -90,6 +97,22 @@ class WatchedService { logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`); } + // Sync to MAL + const isMalAuth = await MalAuthService.isAuthenticated(); + if (isMalAuth && showImdbId) { + // We need the title for scrobbleEpisode (as fallback), + // but getMalId will now prioritize the IMDb mapping. + // We'll use a placeholder title or try to find it if possible. + MalSync.scrobbleEpisode( + 'Anime', // Title fallback + episode, + 0, // Total episodes (MalSync will fetch) + 'series', + season, + showImdbId + ).catch(err => logger.error('[WatchedService] MAL sync failed:', err)); + } + // Store locally as "completed" const episodeId = `${showId}:${season}:${episode}`; await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);