feat(mal): improved mapping logic with direct ID support, TMDB-based resolution, and same-day batch support

This commit is contained in:
paregi12 2026-03-12 15:15:55 +05:30
parent bd432b438c
commit b093e4933c
8 changed files with 258 additions and 38 deletions

View file

@ -34,7 +34,15 @@ interface SeriesContentProps {
onSeasonChange: (season: number) => void;
onSelectEpisode: (episode: Episode) => void;
groupedEpisodes?: { [seasonNumber: number]: Episode[] };
metadata?: { poster?: string; id?: string; name?: string };
metadata?: {
poster?: string;
id?: string;
name?: string;
mal_id?: number;
external_ids?: {
mal_id?: number;
}
};
imdbId?: string; // IMDb ID for Trakt sync
}
@ -574,15 +582,31 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
// 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.markEpisodeAsWatched(
showImdbId,
metadata.id,
showImdbId || 'Anime',
metadata.id || '',
episode.season_number,
episode.episode_number,
new Date(),
episode.air_date,
metadata?.name
metadata?.name,
malId,
dayIndex,
tmdbId
);
// Reload to ensure consistency (e.g. if optimistic update was slightly off or for other effects)

View file

@ -203,6 +203,21 @@ const AndroidVideoPlayer: React.FC = () => {
episodeId: episodeId
});
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(() => {
if (!releaseDate || !groupedEpisodes) return 0;
// Flatten groupedEpisodes to search for same-day releases
const allEpisodes = Object.values(groupedEpisodes).flat();
const sameDayEpisodes = allEpisodes
.filter(ep => ep.air_date === releaseDate)
.sort((a, b) => a.episode_number - b.episode_number);
const idx = sameDayEpisodes.findIndex(ep => ep.episode_number === episode);
return idx >= 0 ? idx : 0;
}, [releaseDate, groupedEpisodes, episode]);
const watchProgress = useWatchProgress(
id, type, episodeId,
playerState.currentTime,
@ -214,7 +229,10 @@ const AndroidVideoPlayer: React.FC = () => {
imdbId,
season,
episode,
releaseDate
releaseDate,
currentMalId,
currentDayIndex,
currentTmdbId
);
const gestureControls = usePlayerGestureControls({

View file

@ -222,6 +222,21 @@ const KSPlayerCore: React.FC = () => {
isMounted
});
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(() => {
if (!releaseDate || !groupedEpisodes) return 0;
// Flatten groupedEpisodes to search for same-day releases
const allEpisodes = Object.values(groupedEpisodes).flat() as any[];
const sameDayEpisodes = allEpisodes
.filter(ep => ep.air_date === releaseDate)
.sort((a, b) => a.episode_number - b.episode_number);
const idx = sameDayEpisodes.findIndex(ep => ep.episode_number === episode);
return idx >= 0 ? idx : 0;
}, [releaseDate, groupedEpisodes, episode]);
const watchProgress = useWatchProgress(
id, type, episodeId,
currentTime,
@ -233,7 +248,10 @@ const KSPlayerCore: React.FC = () => {
imdbId,
season,
episode,
releaseDate
releaseDate,
currentMalId,
currentDayIndex,
currentTmdbId
);
// Gestures

View file

@ -19,7 +19,10 @@ export const useWatchProgress = (
imdbId?: string,
season?: number,
episode?: number,
releaseDate?: string
releaseDate?: string,
malId?: number,
dayIndex?: number,
tmdbId?: number
) => {
const [resumePosition, setResumePosition] = useState<number | null>(null);
const [savedDuration, setSavedDuration] = useState<number | null>(null);
@ -38,6 +41,20 @@ export const useWatchProgress = (
const seasonRef = useRef(season);
const episodeRef = useRef(episode);
const releaseDateRef = useRef(releaseDate);
const malIdRef = useRef(malId);
const dayIndexRef = useRef(dayIndex);
const tmdbIdRef = useRef(tmdbId);
// Sync refs
useEffect(() => {
imdbIdRef.current = imdbId;
seasonRef.current = season;
episodeRef.current = episode;
releaseDateRef.current = releaseDate;
malIdRef.current = malId;
dayIndexRef.current = dayIndex;
tmdbIdRef.current = tmdbId;
}, [imdbId, season, episode, releaseDate, malId, dayIndex, tmdbId]);
// Reset scrobble flag when content changes
useEffect(() => {
@ -154,6 +171,9 @@ export const useWatchProgress = (
const currentSeason = seasonRef.current;
const currentEpisode = episodeRef.current;
const currentReleaseDate = releaseDateRef.current;
const currentMalId = malIdRef.current;
const currentDayIndex = dayIndexRef.current;
const currentTmdbId = tmdbIdRef.current;
if (type === 'series' && currentImdbId && currentSeason !== undefined && currentEpisode !== undefined) {
watchedService.markEpisodeAsWatched(
@ -162,10 +182,14 @@ export const useWatchProgress = (
currentSeason,
currentEpisode,
new Date(),
currentReleaseDate
currentReleaseDate,
undefined,
currentMalId,
currentDayIndex,
currentTmdbId
);
} else if (type === 'movie' && currentImdbId) {
watchedService.markMovieAsWatched(currentImdbId);
watchedService.markMovieAsWatched(currentImdbId, new Date(), currentMalId, currentTmdbId);
}
}
} catch (error) {

View file

@ -86,6 +86,13 @@ export interface StreamingContent {
[key: string]: any;
};
imdb_id?: string;
mal_id?: number;
external_ids?: {
mal_id?: number;
imdb_id?: string;
tmdb_id?: number;
tvdb_id?: number;
};
slug?: string;
releaseInfo?: string;
traktSource?: 'watchlist' | 'continue-watching' | 'watched';

View file

@ -31,9 +31,10 @@ export const ArmSyncService = {
*
* @param imdbId The IMDb ID of the show
* @param releaseDateStr The air date of the episode (YYYY-MM-DD)
* @param dayIndex The 0-based index of this episode among others released on the same day (optional)
* @returns {Promise<DateSyncResult | null>} The resolved MAL ID and Episode number
*/
resolveByDate: async (imdbId: string, releaseDateStr: string): Promise<DateSyncResult | null> => {
resolveByDate: async (imdbId: string, releaseDateStr: string, dayIndex?: number): Promise<DateSyncResult | null> => {
try {
// Basic validation: ensure date is in YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}/.test(releaseDateStr)) {
@ -59,7 +60,54 @@ export const ArmSyncService = {
logger.log(`[ArmSync] Found candidates: ${malIds.join(', ')}`);
// 2. Validate Candidates via Jikan Dates
// 2. Validate Candidates
return await ArmSyncService.resolveFromMalCandidates(malIds, releaseDateStr, dayIndex);
} catch (e) {
logger.error('[ArmSync] Resolution failed:', e);
}
return null;
},
/**
* Resolves the correct MyAnimeList ID and Episode Number using ARM (for ID mapping)
* and Jikan (for Air Date matching) using a TMDB ID.
*
* @param tmdbId The TMDB ID of the show
* @param releaseDateStr The air date of the episode (YYYY-MM-DD)
* @param dayIndex The 0-based index of this episode among others released on the same day
* @returns {Promise<DateSyncResult | null>} The resolved MAL ID and Episode number
*/
resolveByTmdb: async (tmdbId: number, releaseDateStr: string, dayIndex?: number): Promise<DateSyncResult | null> => {
try {
if (!/^\d{4}-\d{2}-\d{2}/.test(releaseDateStr)) return null;
logger.log(`[ArmSync] Resolving TMDB ${tmdbId} for date ${releaseDateStr}...`);
// 1. Fetch Candidates from ARM using TMDB ID
const armRes = await axios.get<ArmEntry[]>(`${ARM_BASE}/tmdb`, {
params: { id: tmdbId }
});
const malIds = armRes.data
.map(entry => entry.myanimelist)
.filter((id): id is number => !!id);
if (malIds.length === 0) return null;
logger.log(`[ArmSync] Found candidates for TMDB ${tmdbId}: ${malIds.join(', ')}`);
// 2. Validate Candidates
return await ArmSyncService.resolveFromMalCandidates(malIds, releaseDateStr, dayIndex);
} catch (e) {
logger.error('[ArmSync] TMDB resolution failed:', e);
}
return null;
},
/**
* Internal helper to find the correct MAL ID from a list of candidates based on date
*/
resolveFromMalCandidates: async (malIds: number[], releaseDateStr: string, dayIndex?: number): Promise<DateSyncResult | null> => {
// Helper to delay (Jikan Rate Limit: 3 req/sec)
const delay = (ms: number) => new Promise(res => setTimeout(res, ms));
@ -96,7 +144,7 @@ export const ArmSyncService = {
const epsRes = await axios.get(`${JIKAN_BASE}/anime/${malId}/episodes`);
const episodes = epsRes.data.data;
const matchEp = episodes.find((ep: any) => {
const matchingEpisodes = episodes.filter((ep: any) => {
if (!ep.aired) return false;
try {
const epDate = new Date(ep.aired);
@ -113,7 +161,20 @@ export const ArmSyncService = {
}
});
if (matchEp) {
if (matchingEpisodes.length > 0) {
// Sort matching episodes by their mal_id to ensure consistent ordering
matchingEpisodes.sort((a: any, b: any) => a.mal_id - b.mal_id);
let matchEp = matchingEpisodes[0];
// If multiple episodes match the same day, use dayIndex to pick the correct one
if (matchingEpisodes.length > 1 && dayIndex !== undefined) {
// If the dayIndex is within bounds, pick it. Otherwise, pick the last one.
const idx = Math.min(dayIndex, matchingEpisodes.length - 1);
matchEp = matchingEpisodes[idx];
logger.log(`[ArmSync] Disambiguated same-day release using dayIndex ${dayIndex} -> picked Ep #${matchEp.mal_id}`);
}
logger.log(`[ArmSync] Episode resolved: #${matchEp.mal_id} (${matchEp.title})`);
return {
malId,
@ -126,10 +187,6 @@ export const ArmSyncService = {
logger.warn(`[ArmSync] Failed to check candidate ${malId}:`, e);
}
}
} catch (e) {
logger.error('[ArmSync] Resolution failed:', e);
}
return null;
return null;
}
};

View file

@ -45,7 +45,7 @@ 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, episode: number = 1, releaseDate?: string): Promise<number | null> => {
getMalId: async (title: string, type: 'movie' | 'series' = 'series', year?: number, season?: number, imdbId?: string, episode: number = 1, releaseDate?: string, dayIndex?: number, tmdbId?: number): Promise<number | null> => {
// Safety check: Never perform a MAL search for generic placeholders or empty strings.
// This prevents "cache poisoning" where a generic term matches a random anime.
const cleanTitle = title.trim();
@ -64,12 +64,25 @@ export const MalSync = {
return cachedId;
}
if (isGenericTitle && !imdbId) return null;
if (isGenericTitle && !imdbId && !tmdbId) return null;
// 1. Try ARM + Jikan Sync (Most accurate for perfect season/episode matching)
// 1. Try TMDB-based Resolution (High Accuracy)
if (tmdbId && releaseDate) {
try {
const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex);
if (tmdbResult && tmdbResult.malId) {
console.log(`[MalSync] Found TMDB match: ${tmdbId} (${releaseDate}) -> MAL ${tmdbResult.malId}`);
return tmdbResult.malId;
}
} catch (e) {
console.warn('[MalSync] TMDB Sync failed:', e);
}
}
// 2. Try ARM + Jikan Sync (IMDb fallback)
if (imdbId && type === 'series' && releaseDate) {
try {
const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate);
const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate, dayIndex);
if (armResult && armResult.malId) {
console.log(`[MalSync] Found ARM match: ${imdbId} (${releaseDate}) -> MAL ${armResult.malId} Ep ${armResult.episode}`);
// Note: ArmSyncService returns the *absolute* episode number for MAL (e.g. 76)
@ -100,6 +113,10 @@ export const MalSync = {
if (type === 'series' && season && season > 1) {
// Improve search query: "Attack on Titan Season 2" usually works better than just appending
searchQuery = `${cleanTitle} Season ${season}`;
} else if (type === 'series' && season === 0) {
// Improve Season 0 (Specials) lookup: "Attack on Titan Specials" or "Attack on Titan OVA"
// We search for both to find the most likely entry
searchQuery = `${cleanTitle} Specials`;
}
const result = await MalApiService.searchAnime(searchQuery, 10);
@ -109,6 +126,17 @@ export const MalSync = {
// Filter by type first
if (type === 'movie') {
candidates = candidates.filter(r => r.node.media_type === 'movie');
} else if (season === 0) {
// For Season 0, prioritize specials, ovas, and onas
candidates = candidates.filter(r => r.node.media_type === 'special' || r.node.media_type === 'ova' || r.node.media_type === 'ona');
if (candidates.length === 0) {
// If no specific special types found, fallback to anything containing "Special" or "OVA" in title
candidates = result.data.filter(r =>
r.node.title.toLowerCase().includes('special') ||
r.node.title.toLowerCase().includes('ova') ||
r.node.title.toLowerCase().includes('ona')
);
}
} else {
candidates = candidates.filter(r => r.node.media_type === 'tv' || r.node.media_type === 'ona' || r.node.media_type === 'special' || r.node.media_type === 'ova');
}
@ -150,7 +178,10 @@ export const MalSync = {
type: 'movie' | 'series' = 'series',
season?: number,
imdbId?: string,
releaseDate?: string
releaseDate?: string,
providedMalId?: number, // Optional: skip lookup if already known
dayIndex?: number, // 0-based index of episode in a same-day release batch
tmdbId?: number
) => {
try {
// Requirement 9 & 10: Respect user settings and safety
@ -161,12 +192,22 @@ export const MalSync = {
return;
}
let malId: number | null = null;
let malId: number | null = providedMalId || null;
let finalEpisodeNumber = episodeNumber;
// Try ARM Sync first to get exact MAL ID and absolute episode number
if (imdbId && type === 'series' && releaseDate) {
const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate);
// Strategy 1: TMDB-based Resolution (High Accuracy for Specials)
if (!malId && tmdbId && releaseDate) {
const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex);
if (tmdbResult) {
malId = tmdbResult.malId;
finalEpisodeNumber = tmdbResult.episode;
console.log(`[MalSync] TMDB Resolved: ${animeTitle} -> MAL ${malId} Ep ${finalEpisodeNumber}`);
}
}
// Strategy 2: IMDb-based Resolution (Fallback)
if (!malId && imdbId && type === 'series' && releaseDate) {
const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate, dayIndex);
if (armResult) {
malId = armResult.malId;
finalEpisodeNumber = armResult.episode;
@ -174,9 +215,9 @@ export const MalSync = {
}
}
// Fallback to standard lookup if ARM failed or not applicable
// Fallback to standard lookup if ARM/TMDB failed and no ID provided
if (!malId) {
malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber, releaseDate);
malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber, releaseDate, dayIndex, tmdbId);
}
if (!malId) return;

View file

@ -35,7 +35,9 @@ class WatchedService {
*/
public async markMovieAsWatched(
imdbId: string,
watchedAt: Date = new Date()
watchedAt: Date = new Date(),
malId?: number,
tmdbId?: number
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`);
@ -58,7 +60,11 @@ class WatchedService {
1,
'movie',
undefined,
imdbId
imdbId,
undefined,
malId,
undefined,
tmdbId
).catch(err => logger.error('[WatchedService] MAL movie sync failed:', err));
}
@ -94,7 +100,10 @@ class WatchedService {
episode: number,
watchedAt: Date = new Date(),
releaseDate?: string, // Optional release date for precise matching
showTitle?: string
showTitle?: string,
malId?: number,
dayIndex?: number,
tmdbId?: number
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`);
@ -115,12 +124,31 @@ class WatchedService {
// Sync to MAL
const malToken = MalAuth.getToken();
if (malToken && showImdbId) {
// Strategy 1: "Perfect Match" using ARM + Release Date
if (malToken && (showImdbId || malId || tmdbId)) {
// Strategy 0: Direct Match (if malId is provided)
let synced = false;
if (releaseDate) {
if (malId) {
await MalSync.scrobbleDirect(malId, episode);
synced = true;
}
// Strategy 1: TMDB-based Resolution (High Accuracy for Specials)
if (!synced && releaseDate && tmdbId) {
try {
const armResult = await ArmSyncService.resolveByDate(showImdbId, releaseDate);
const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex);
if (tmdbResult) {
await MalSync.scrobbleDirect(tmdbResult.malId, tmdbResult.episode);
synced = true;
}
} catch (e) {
logger.warn('[WatchedService] TMDB Sync failed, falling back to IMDb:', e);
}
}
// Strategy 2: IMDb-based Resolution (Fallback)
if (!synced && releaseDate && showImdbId) {
try {
const armResult = await ArmSyncService.resolveByDate(showImdbId, releaseDate, dayIndex);
if (armResult) {
await MalSync.scrobbleDirect(armResult.malId, armResult.episode);
synced = true;
@ -130,7 +158,7 @@ class WatchedService {
}
}
// Strategy 2: Offline Mapping Fallback
// Strategy 3: Offline Mapping / Search Fallback
if (!synced) {
MalSync.scrobbleEpisode(
showTitle || showImdbId || 'Anime',
@ -139,7 +167,10 @@ class WatchedService {
'series',
season,
showImdbId,
releaseDate // Pass releaseDate for better matching
releaseDate,
malId,
dayIndex,
tmdbId
).catch(err => logger.error('[WatchedService] MAL sync failed:', err));
}
}