feat: integrate offline MyAnimeList syncing with MappingService

This commit is contained in:
paregi12 2026-01-17 10:12:22 +05:30
parent 744f79a264
commit 2b43893f4d
10 changed files with 194 additions and 60 deletions

47
analyze_mappings.py Normal file
View file

@ -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}")

9
inspect_aot.py Normal file
View file

@ -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()

19
inspect_db.py Normal file
View file

@ -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()

14
inspect_duplicates.py Normal file
View file

@ -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()

15
inspect_episodes.py Normal file
View file

@ -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()

View file

@ -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,

View file

@ -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.
*/

View file

@ -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<number | null> => {
// 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<number | 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}`;
@ -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;

View file

@ -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) {

View file

@ -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);