feat(mal): improve season-aware scrobbling and UI refinements

This commit is contained in:
paregi12 2026-02-09 20:36:28 +05:30
parent 28e62fa674
commit b7c0bc3304
7 changed files with 81 additions and 74 deletions

View file

@ -579,24 +579,12 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
showImdbId,
metadata.id,
episode.season_number,
episode.episode_number
episode.episode_number,
new Date(),
episode.air_date,
metadata?.name
);
// Sync to MAL
const malEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true;
if (malEnabled && metadata?.name) {
const totalEpisodes = Object.values(groupedEpisodes).reduce((acc, curr) => acc + (curr?.length || 0), 0);
MalSync.scrobbleEpisode(
metadata.name,
episode.episode_number,
totalEpisodes,
'series',
episode.season_number,
imdbId,
episode.air_date // Pass release date for ARM Sync converter
);
}
// Reload to ensure consistency (e.g. if optimistic update was slightly off or for other effects)
// But we don't strictly *need* to wait for this to update UI
loadEpisodesProgress();
@ -2383,4 +2371,4 @@ const styles = StyleSheet.create({
fontWeight: '800',
},
});
});

View file

@ -78,6 +78,7 @@ interface PlayerRouteParams {
backdrop?: string;
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
headers?: Record<string, string>;
releaseDate?: string;
initialPosition?: number;
}
@ -232,7 +233,7 @@ const KSPlayerCore: React.FC = () => {
imdbId,
season,
episode,
undefined // releaseDate not yet implemented for iOS
releaseDate
);
// Gestures
@ -1159,4 +1160,4 @@ const KSPlayerCore: React.FC = () => {
);
};
export default KSPlayerCore;
export default KSPlayerCore;

View file

@ -203,12 +203,20 @@ const MalSettingsScreen: React.FC = () => {
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary, marginTop: 12 }]}
onPress={() => {
onPress={async () => {
setIsLoading(true);
MalSync.syncMalToLibrary().then(() => {
try {
const synced = await MalSync.syncMalToLibrary();
if (synced) {
openAlert('Sync Complete', 'MAL data has been refreshed.');
} else {
openAlert('Sync Failed', 'Could not refresh MAL data.');
}
} catch {
openAlert('Sync Failed', 'Could not refresh MAL data.');
} finally {
setIsLoading(false);
openAlert('Sync Complete', 'MAL data has been refreshed.');
});
}
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>

View file

@ -378,31 +378,29 @@ const SettingsScreen: React.FC = () => {
return (
<SettingsCard title={t('settings.sections.account')} isTablet={isTablet}>
{isItemVisible('trakt') && (
<SettingItem
title={t('trakt.title')}
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
customIcon={<TraktIcon size={isTablet ? 24 : 20} />}
<SettingItem
title={t('trakt.title')}
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
customIcon={<TraktIcon size={isTablet ? 24 : 20} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')}
isLast={!isItemVisible('simkl')}
isLast={!isItemVisible('simkl') && !isItemVisible('mal')}
isTablet={isTablet}
/>
)}
{isItemVisible('simkl') && (
<SettingItem
title={t('settings.items.simkl')}
description={isSimklAuthenticated ? t('settings.items.simkl_connected') : t('settings.items.simkl_desc')}
customIcon={<SimklIcon size={isTablet ? 24 : 20} />}
<SettingItem
title={t('settings.items.simkl')}
description={isSimklAuthenticated ? t('settings.items.simkl_connected') : t('settings.items.simkl_desc')}
customIcon={<SimklIcon size={isTablet ? 24 : 20} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('SimklSettings')}
isLast={false}
isLast={!isItemVisible('mal')}
isTablet={isTablet}
/>
)}
isTablet={isTablet}
/>
)}
<SettingItem
{isItemVisible('mal') && (
<SettingItem
title="MyAnimeList"
description="Sync with MyAnimeList"
customIcon={<Image source={require('../../assets/rating-icons/mal-icon.png')} style={{ width: isTablet ? 24 : 20, height: isTablet ? 24 : 20, borderRadius: 4 }} resizeMode="contain" />}
@ -410,7 +408,8 @@ const SettingsScreen: React.FC = () => {
onPress={() => navigation.navigate('MalSettings')}
isLast={true}
isTablet={isTablet}
/>
/>
)}
</SettingsCard>
);
@ -695,7 +694,7 @@ const SettingsScreen: React.FC = () => {
contentContainerStyle={styles.scrollContent}
>
{/* Account */}
{(settingsConfig?.categories?.['account']?.visible !== false) && (isItemVisible('trakt') || isItemVisible('simkl')) && (
{(settingsConfig?.categories?.['account']?.visible !== false) && (isItemVisible('trakt') || isItemVisible('simkl') || isItemVisible('mal')) && (
<SettingsCard title={t('settings.account').toUpperCase()}>
{isItemVisible('trakt') && (
<SettingItem
@ -704,7 +703,7 @@ const SettingsScreen: React.FC = () => {
customIcon={<TraktIcon size={20} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')}
isLast={!isItemVisible('simkl')}
isLast={!isItemVisible('simkl') && !isItemVisible('mal')}
/>
)}
{isItemVisible('simkl') && (
@ -714,19 +713,19 @@ const SettingsScreen: React.FC = () => {
customIcon={<SimklIcon size={20} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('SimklSettings')}
isLast={false}
isLast={!isItemVisible('mal')}
/>
)}
/>
)}
<SettingItem
{isItemVisible('mal') && (
<SettingItem
title="MyAnimeList"
description="Sync with MyAnimeList"
customIcon={<Image source={require('../../assets/rating-icons/mal-icon.png')} style={{ width: 20, height: 20, borderRadius: 4 }} resizeMode="contain" />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('MalSettings')}
isLast
/>
/>
)}
</SettingsCard>
)}
@ -1234,4 +1233,4 @@ const styles = StyleSheet.create({
},
});
export default SettingsScreen;
export default SettingsScreen;

View file

@ -6,6 +6,10 @@ import { ArmSyncService } from './ArmSyncService';
import axios from 'axios';
const MAPPING_PREFIX = 'mal_map_';
const getTitleCacheKey = (title: string, type: 'movie' | 'series', season = 1) =>
`${MAPPING_PREFIX}${title.trim()}_${type}_${season}`;
const getLegacyTitleCacheKey = (title: string, type: 'movie' | 'series') =>
`${MAPPING_PREFIX}${title.trim()}_${type}`;
export const MalSync = {
/**
@ -44,15 +48,24 @@ export const MalSync = {
getMalId: async (title: string, type: 'movie' | 'series' = 'series', year?: number, season?: number, imdbId?: string, episode: number = 1, releaseDate?: string): 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 cleanTitle = title.trim();
const normalizedTitle = cleanTitle.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;
const seasonNumber = season || 1;
const cacheKey = getTitleCacheKey(cleanTitle, type, seasonNumber);
const legacyCacheKey = getLegacyTitleCacheKey(cleanTitle, type);
const cachedId = mmkvStorage.getNumber(cacheKey) || mmkvStorage.getNumber(legacyCacheKey);
if (cachedId) {
// Backfill to season-aware key for future lookups.
if (!mmkvStorage.getNumber(cacheKey)) {
mmkvStorage.setNumber(cacheKey, cachedId);
}
return cachedId;
}
if (isGenericTitle && !imdbId) return null;
// 1. Try ARM + Jikan Sync (Most accurate for perfect season/episode matching)
if (imdbId && type === 'series' && releaseDate) {
try {
@ -72,13 +85,11 @@ export const MalSync = {
}
}
// 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;
// 2. Try IMDb ID mapping when it is likely to be accurate, or when title is generic.
if (imdbId && (type === 'movie' || seasonNumber <= 1 || isGenericTitle)) {
const idFromImdb = await MalSync.getMalIdFromImdb(imdbId);
if (idFromImdb) return idFromImdb;
}
// 3. Search MAL (Skip if generic title)
if (isGenericTitle) return null;
@ -120,6 +131,7 @@ export const MalSync = {
// Save to cache
mmkvStorage.setNumber(cacheKey, bestMatch.id);
mmkvStorage.setNumber(legacyCacheKey, bestMatch.id);
return bestMatch.id;
}
} catch (e) {
@ -290,8 +302,10 @@ export const MalSync = {
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);
const title = item.node.title.trim();
mmkvStorage.setNumber(getTitleCacheKey(title, type, 1), item.node.id);
// Keep legacy key for backwards compatibility with old cache readers.
mmkvStorage.setNumber(getLegacyTitleCacheKey(title, type), item.node.id);
}
console.log(`[MalSync] Synced ${allItems.length} items to mapping cache.`);
return true;
@ -304,9 +318,11 @@ export const MalSync = {
/**
* 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);
setMapping: (title: string, malId: number, type: 'movie' | 'series' = 'series', season: number = 1) => {
const cleanTitle = title.trim();
mmkvStorage.setNumber(getTitleCacheKey(cleanTitle, type, season), malId);
// Keep legacy key for compatibility.
mmkvStorage.setNumber(getLegacyTitleCacheKey(cleanTitle, type), malId);
},
/**
@ -449,4 +465,3 @@ export const MalSync = {
}
}
};

View file

@ -1476,7 +1476,6 @@ class LocalScraperService {
}
};
>>>>>>> upstream/main
// Execution timeout (1 minute)
const PLUGIN_TIMEOUT_MS = 60000;
const functionName = params.functionName || 'getStreams';
@ -1829,4 +1828,4 @@ class LocalScraperService {
export const localScraperService = LocalScraperService.getInstance();
export const pluginService = localScraperService; // Alias for UI consistency
export default localScraperService;
export default localScraperService;

View file

@ -53,7 +53,7 @@ class WatchedService {
const malToken = MalAuth.getToken();
if (malToken) {
MalSync.scrobbleEpisode(
'Movie',
imdbId,
1,
1,
'movie',
@ -93,7 +93,8 @@ class WatchedService {
season: number,
episode: number,
watchedAt: Date = new Date(),
releaseDate?: string // Optional release date for precise matching
releaseDate?: string, // Optional release date for precise matching
showTitle?: string
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`);
@ -132,7 +133,7 @@ class WatchedService {
// Strategy 2: Offline Mapping Fallback
if (!synced) {
MalSync.scrobbleEpisode(
'Anime', // Title fallback
showTitle || showImdbId || 'Anime',
episode,
0,
'series',
@ -411,10 +412,6 @@ class WatchedService {
showImdbId,
season
);
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
showImdbId,
season
);
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
}