mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-18 15:22:05 +00:00
feat: integrate offline MyAnimeList syncing with MappingService
This commit is contained in:
parent
744f79a264
commit
2b43893f4d
10 changed files with 194 additions and 60 deletions
47
analyze_mappings.py
Normal file
47
analyze_mappings.py
Normal 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
9
inspect_aot.py
Normal 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
19
inspect_db.py
Normal 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
14
inspect_duplicates.py
Normal 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
15
inspect_episodes.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue