mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-05 01:09:46 +00:00
feat(mal): improve season-aware scrobbling and UI refinements
This commit is contained in:
parent
28e62fa674
commit
b7c0bc3304
7 changed files with 81 additions and 74 deletions
|
|
@ -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',
|
||||
},
|
||||
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue