NuvioStreaming/src/services/mal/MalSync.ts

424 lines
16 KiB
TypeScript

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_';
export const MalSync = {
/**
* Tries to find a MAL ID using IMDb ID via MAL-Sync API.
*/
getMalIdFromImdb: async (imdbId: string): Promise<number | null> => {
if (!imdbId) return null;
// 1. Check Cache
const cacheKey = `${MAPPING_PREFIX}imdb_${imdbId}`;
const cachedId = mmkvStorage.getNumber(cacheKey);
if (cachedId) return cachedId;
// 2. Fetch from MAL-Sync API
try {
// Ensure ID format
const cleanId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
const response = await axios.get(`https://api.malsync.moe/mal/anime/imdb/${cleanId}`);
if (response.data && response.data.id) {
const malId = response.data.id;
// Save to cache
mmkvStorage.setNumber(cacheKey, malId);
return malId;
}
} catch (e) {
// Ignore errors (404, etc.)
}
return null;
},
/**
* 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): 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 normalizedTitle = title.trim().toLowerCase();
const isGenericTitle = !normalizedTitle || normalizedTitle === 'anime' || normalizedTitle === 'movie';
if (isGenericTitle) {
// If we have an offline mapping, we can still try it below,
// but we MUST skip the fuzzy search logic at the end.
if (!imdbId) return 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}`;
const cachedId = mmkvStorage.getNumber(cacheKey);
if (cachedId) return cachedId;
// 3. Search MAL (Skip if generic title)
if (isGenericTitle) return null;
try {
let searchQuery = cleanTitle;
// For Season 2+, explicitly search for that season
if (type === 'series' && season && season > 1) {
// Improve search query: "Attack on Titan Season 2" usually works better than just appending
searchQuery = `${cleanTitle} Season ${season}`;
}
const result = await MalApiService.searchAnime(searchQuery, 10);
if (result.data.length > 0) {
let candidates = result.data;
// Filter by type first
if (type === 'movie') {
candidates = candidates.filter(r => r.node.media_type === 'movie');
} 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');
}
if (candidates.length === 0) candidates = result.data; // Fallback to all if type filtering removes everything
let bestMatch = candidates[0].node;
// If year is provided, try to find an exact start year match
if (year) {
const yearMatch = candidates.find(r => r.node.start_season?.year === year);
if (yearMatch) {
bestMatch = yearMatch.node;
} else {
// Fuzzy year match (+/- 1 year)
const fuzzyMatch = candidates.find(r => r.node.start_season?.year && Math.abs(r.node.start_season.year - year) <= 1);
if (fuzzyMatch) bestMatch = fuzzyMatch.node;
}
}
// Save to cache
mmkvStorage.setNumber(cacheKey, bestMatch.id);
return bestMatch.id;
}
} catch (e) {
console.warn('MAL Search failed for', title);
}
return null;
},
/**
* Main function to track progress
*/
scrobbleEpisode: async (
animeTitle: string,
episodeNumber: number,
totalEpisodes: number = 0,
type: 'movie' | 'series' = 'series',
season?: number,
imdbId?: string
) => {
try {
// Requirement 9 & 10: Respect user settings and safety
const isEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true;
const isAutoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true;
if (!isEnabled || !isAutoUpdate) {
return;
}
const malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber);
if (!malId) return;
// Check current status on MAL to avoid overwriting completed/dropped shows
try {
const currentInfo = await MalApiService.getMyListStatus(malId);
const currentStatus = currentInfo.my_list_status?.status;
const currentEpisodesWatched = currentInfo.my_list_status?.num_episodes_watched || 0;
// Requirement 4: Auto-Add Anime to MAL (Configurable)
if (!currentStatus) {
const autoAdd = mmkvStorage.getBoolean('mal_auto_add') ?? true;
if (!autoAdd) {
console.log(`[MalSync] Skipping scrobble for ${animeTitle}: Not in list and auto-add disabled`);
return;
}
}
// If already completed or dropped, don't auto-update via scrobble
if (currentStatus === 'completed' || currentStatus === 'dropped') {
console.log(`[MalSync] Skipping update for ${animeTitle}: Status is ${currentStatus}`);
return;
}
// If we are just starting (ep 1) or resuming (plan_to_watch/on_hold/null), set to watching
// Also ensure we don't downgrade episode count (though unlikely with scrobbling forward)
if (episodeNumber <= currentEpisodesWatched) {
console.log(`[MalSync] Skipping update for ${animeTitle}: Episode ${episodeNumber} <= Current ${currentEpisodesWatched}`);
return;
}
} catch (e) {
// If error (e.g. not found), proceed to add it
}
let finalTotalEpisodes = totalEpisodes;
// If totalEpisodes not provided, try to fetch it from MAL details
if (finalTotalEpisodes <= 0) {
try {
const details = await MalApiService.getAnimeDetails(malId);
if (details && details.num_episodes) {
finalTotalEpisodes = details.num_episodes;
}
} catch (e) {
// Fallback to 0 if details fetch fails
}
}
// Determine Status
let status: MalListStatus = 'watching';
if (finalTotalEpisodes > 0 && episodeNumber >= finalTotalEpisodes) {
status = 'completed';
}
await MalApiService.updateStatus(malId, status, episodeNumber);
console.log(`[MalSync] Synced ${animeTitle} Ep ${episodeNumber}/${finalTotalEpisodes || '?'} -> MAL ID ${malId} (${status})`);
} catch (e) {
console.error('[MalSync] Scrobble failed:', e);
}
},
/**
* Direct scrobble with known MAL ID and Episode
* Used when ArmSync has already resolved the exact details.
*/
scrobbleDirect: async (malId: number, episodeNumber: number) => {
try {
// Respect user settings
const isEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true;
const isAutoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true;
if (!isEnabled || !isAutoUpdate) return;
// Check current status
const currentInfo = await MalApiService.getMyListStatus(malId);
const currentStatus = currentInfo.my_list_status?.status;
// Auto-Add check
if (!currentStatus) {
const autoAdd = mmkvStorage.getBoolean('mal_auto_add') ?? true;
if (!autoAdd) {
console.log(`[MalSync] Skipping direct scrobble: Not in list and auto-add disabled`);
return;
}
}
// Safety checks (Completed/Dropped/Regression)
if (currentStatus === 'completed' || currentStatus === 'dropped') return;
if (currentInfo.my_list_status?.num_episodes_watched && episodeNumber <= currentInfo.my_list_status.num_episodes_watched) return;
// Determine Status
let status: MalListStatus = 'watching';
if (currentInfo.num_episodes > 0 && episodeNumber >= currentInfo.num_episodes) {
status = 'completed';
}
await MalApiService.updateStatus(malId, status, episodeNumber);
console.log(`[MalSync] Direct synced MAL ID ${malId} Ep ${episodeNumber} (${status})`);
} catch (e) {
console.error('[MalSync] Direct scrobble failed:', e);
}
},
/**
* Import MAL list items into local library
*/
syncMalToLibrary: async () => {
try {
let allItems: MalAnimeNode[] = [];
let offset = 0;
let hasMore = true;
while (hasMore && offset < 1000) { // Limit to 1000 items for safety
const response = await MalApiService.getUserList(undefined, offset, 100);
if (response.data && response.data.length > 0) {
allItems = [...allItems, ...response.data];
offset += response.data.length;
hasMore = !!response.paging.next;
} else {
hasMore = false;
}
}
for (const item of allItems) {
const type = item.node.media_type === 'movie' ? 'movie' : 'series';
const cacheKey = `${MAPPING_PREFIX}${item.node.title.trim()}_${type}`;
mmkvStorage.setNumber(cacheKey, item.node.id);
}
console.log(`[MalSync] Synced ${allItems.length} items to mapping cache.`);
return true;
} catch (e) {
console.error('syncMalToLibrary failed', e);
return false;
}
},
/**
* Manually map an ID if auto-detection fails
*/
setMapping: (title: string, malId: number, type: 'movie' | 'series' = 'series') => {
const cacheKey = `${MAPPING_PREFIX}${title.trim()}_${type}`;
mmkvStorage.setNumber(cacheKey, malId);
},
/**
* Get external IDs (IMDb, etc.) and season info from a MAL ID using MalSync API
*/
getIdsFromMalId: async (malId: number): Promise<{ imdbId: string | null; season: number }> => {
const cacheKey = `mal_ext_ids_v2_${malId}`;
const cached = mmkvStorage.getString(cacheKey);
if (cached) {
return JSON.parse(cached);
}
try {
const response = await axios.get(`https://api.malsync.moe/mal/anime/${malId}`);
const data = response.data;
let imdbId = null;
let season = data.season || 1;
// Try to find IMDb ID in Sites
if (data.Sites && data.Sites.IMDB) {
const imdbKeys = Object.keys(data.Sites.IMDB);
if (imdbKeys.length > 0) {
imdbId = imdbKeys[0];
}
}
const result = { imdbId, season };
mmkvStorage.setString(cacheKey, JSON.stringify(result));
return result;
} catch (e) {
console.error('[MalSync] Failed to fetch external IDs:', e);
}
return { imdbId: null, season: 1 };
},
/**
* Get weekly anime schedule from Jikan API (Adjusted to Local Timezone)
*/
getWeeklySchedule: async (): Promise<any[]> => {
const cacheKey = 'mal_weekly_schedule_local_v2'; // Bump version for new format
const cached = mmkvStorage.getString(cacheKey);
const cacheTime = mmkvStorage.getNumber(`${cacheKey}_time`);
// Cache for 24 hours
if (cached && cacheTime && (Date.now() - cacheTime < 24 * 60 * 60 * 1000)) {
return JSON.parse(cached);
}
try {
// Jikan API rate limit mitigation
await new Promise(resolve => setTimeout(resolve, 500));
const response = await axios.get('https://api.jikan.moe/v4/schedules');
const data = response.data.data;
const daysOrder = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'];
const dayMap: Record<string, number> = { 'Mondays': 0, 'Tuesdays': 1, 'Wednesdays': 2, 'Thursdays': 3, 'Fridays': 4, 'Saturdays': 5, 'Sundays': 6 };
const daysReverse = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'];
const grouped: Record<string, any[]> = {};
// Calculate time difference in minutes: Local - JST (UTC+9)
// getTimezoneOffset() returns minutes BEHIND UTC (positive for US, negative for Asia)
// We want Local - UTC+9.
// Local = UTC - offset.
// Diff = (UTC - localOffset) - (UTC + 540) = -localOffset - 540.
const jstOffset = 540; // UTC+9 in minutes
const localOffset = new Date().getTimezoneOffset(); // e.g. 300 for EST (UTC-5)
const offsetMinutes = -localOffset - jstOffset; // e.g. -300 - 540 = -840 minutes (-14h)
data.forEach((anime: any) => {
let day = anime.broadcast?.day; // "Mondays"
let time = anime.broadcast?.time; // "23:00"
let originalDay = day;
// Adjust to local time
if (day && time && dayMap[day] !== undefined) {
const [hours, mins] = time.split(':').map(Number);
let totalMinutes = hours * 60 + mins + offsetMinutes;
let dayShift = 0;
// Handle day rollovers
if (totalMinutes < 0) {
totalMinutes += 24 * 60;
dayShift = -1;
} else if (totalMinutes >= 24 * 60) {
totalMinutes -= 24 * 60;
dayShift = 1;
}
const newHour = Math.floor(totalMinutes / 60);
const newMin = totalMinutes % 60;
time = `${String(newHour).padStart(2,'0')}:${String(newMin).padStart(2,'0')}`;
let dayIndex = dayMap[day] + dayShift;
if (dayIndex < 0) dayIndex = 6;
if (dayIndex > 6) dayIndex = 0;
day = daysReverse[dayIndex];
} else {
day = 'Other'; // No specific time/day
}
if (!grouped[day]) grouped[day] = [];
grouped[day].push({
id: `mal:${anime.mal_id}`,
seriesId: `mal:${anime.mal_id}`,
title: anime.title,
seriesName: anime.title_english || anime.title,
poster: anime.images?.jpg?.large_image_url || anime.images?.jpg?.image_url,
releaseDate: null,
season: 1,
episode: 1,
overview: anime.synopsis,
vote_average: anime.score,
day: day,
time: time,
genres: anime.genres?.map((g: any) => g.name) || [],
originalDay: originalDay // Keep for debug if needed
});
});
// Sort by day (starting Monday or Today?) -> Standard is Monday start for anime
// Sort items by time within day
const result = [...daysOrder, 'Other']
.filter(day => grouped[day] && grouped[day].length > 0)
.map(day => ({
title: day,
data: grouped[day].sort((a, b) => (a.time || '99:99').localeCompare(b.time || '99:99'))
}));
mmkvStorage.setString(cacheKey, JSON.stringify(result));
mmkvStorage.setNumber(`${cacheKey}_time`, Date.now());
return result;
} catch (e) {
console.error('[MalSync] Failed to fetch schedule:', e);
return [];
}
}
};