mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-21 00:32:04 +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,
|
showImdbId,
|
||||||
metadata.id,
|
metadata.id,
|
||||||
episode.season_number,
|
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)
|
// 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
|
// But we don't strictly *need* to wait for this to update UI
|
||||||
loadEpisodesProgress();
|
loadEpisodesProgress();
|
||||||
|
|
@ -2383,4 +2371,4 @@ const styles = StyleSheet.create({
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ interface PlayerRouteParams {
|
||||||
backdrop?: string;
|
backdrop?: string;
|
||||||
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
|
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
releaseDate?: string;
|
||||||
initialPosition?: number;
|
initialPosition?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,7 +233,7 @@ const KSPlayerCore: React.FC = () => {
|
||||||
imdbId,
|
imdbId,
|
||||||
season,
|
season,
|
||||||
episode,
|
episode,
|
||||||
undefined // releaseDate not yet implemented for iOS
|
releaseDate
|
||||||
);
|
);
|
||||||
|
|
||||||
// Gestures
|
// Gestures
|
||||||
|
|
@ -1159,4 +1160,4 @@ const KSPlayerCore: React.FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default KSPlayerCore;
|
export default KSPlayerCore;
|
||||||
|
|
|
||||||
|
|
@ -203,12 +203,20 @@ const MalSettingsScreen: React.FC = () => {
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.button, { backgroundColor: currentTheme.colors.primary, marginTop: 12 }]}
|
style={[styles.button, { backgroundColor: currentTheme.colors.primary, marginTop: 12 }]}
|
||||||
onPress={() => {
|
onPress={async () => {
|
||||||
setIsLoading(true);
|
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);
|
setIsLoading(false);
|
||||||
openAlert('Sync Complete', 'MAL data has been refreshed.');
|
}
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
|
|
||||||
|
|
@ -378,31 +378,29 @@ const SettingsScreen: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<SettingsCard title={t('settings.sections.account')} isTablet={isTablet}>
|
<SettingsCard title={t('settings.sections.account')} isTablet={isTablet}>
|
||||||
{isItemVisible('trakt') && (
|
{isItemVisible('trakt') && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title={t('trakt.title')}
|
title={t('trakt.title')}
|
||||||
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
|
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
|
||||||
customIcon={<TraktIcon size={isTablet ? 24 : 20} />}
|
customIcon={<TraktIcon size={isTablet ? 24 : 20} />}
|
||||||
renderControl={() => <ChevronRight />}
|
renderControl={() => <ChevronRight />}
|
||||||
onPress={() => navigation.navigate('TraktSettings')}
|
onPress={() => navigation.navigate('TraktSettings')}
|
||||||
isLast={!isItemVisible('simkl')}
|
isLast={!isItemVisible('simkl') && !isItemVisible('mal')}
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isItemVisible('simkl') && (
|
{isItemVisible('simkl') && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title={t('settings.items.simkl')}
|
title={t('settings.items.simkl')}
|
||||||
description={isSimklAuthenticated ? t('settings.items.simkl_connected') : t('settings.items.simkl_desc')}
|
description={isSimklAuthenticated ? t('settings.items.simkl_connected') : t('settings.items.simkl_desc')}
|
||||||
customIcon={<SimklIcon size={isTablet ? 24 : 20} />}
|
customIcon={<SimklIcon size={isTablet ? 24 : 20} />}
|
||||||
renderControl={() => <ChevronRight />}
|
renderControl={() => <ChevronRight />}
|
||||||
onPress={() => navigation.navigate('SimklSettings')}
|
onPress={() => navigation.navigate('SimklSettings')}
|
||||||
isLast={false}
|
isLast={!isItemVisible('mal')}
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
isTablet={isTablet}
|
{isItemVisible('mal') && (
|
||||||
/>
|
<SettingItem
|
||||||
)}
|
|
||||||
<SettingItem
|
|
||||||
title="MyAnimeList"
|
title="MyAnimeList"
|
||||||
description="Sync with 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" />}
|
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')}
|
onPress={() => navigation.navigate('MalSettings')}
|
||||||
isLast={true}
|
isLast={true}
|
||||||
isTablet={isTablet}
|
isTablet={isTablet}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -695,7 +694,7 @@ const SettingsScreen: React.FC = () => {
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
>
|
>
|
||||||
{/* Account */}
|
{/* 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()}>
|
<SettingsCard title={t('settings.account').toUpperCase()}>
|
||||||
{isItemVisible('trakt') && (
|
{isItemVisible('trakt') && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
|
|
@ -704,7 +703,7 @@ const SettingsScreen: React.FC = () => {
|
||||||
customIcon={<TraktIcon size={20} />}
|
customIcon={<TraktIcon size={20} />}
|
||||||
renderControl={() => <ChevronRight />}
|
renderControl={() => <ChevronRight />}
|
||||||
onPress={() => navigation.navigate('TraktSettings')}
|
onPress={() => navigation.navigate('TraktSettings')}
|
||||||
isLast={!isItemVisible('simkl')}
|
isLast={!isItemVisible('simkl') && !isItemVisible('mal')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isItemVisible('simkl') && (
|
{isItemVisible('simkl') && (
|
||||||
|
|
@ -714,19 +713,19 @@ const SettingsScreen: React.FC = () => {
|
||||||
customIcon={<SimklIcon size={20} />}
|
customIcon={<SimklIcon size={20} />}
|
||||||
renderControl={() => <ChevronRight />}
|
renderControl={() => <ChevronRight />}
|
||||||
onPress={() => navigation.navigate('SimklSettings')}
|
onPress={() => navigation.navigate('SimklSettings')}
|
||||||
isLast={false}
|
isLast={!isItemVisible('mal')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
{isItemVisible('mal') && (
|
||||||
)}
|
<SettingItem
|
||||||
<SettingItem
|
|
||||||
title="MyAnimeList"
|
title="MyAnimeList"
|
||||||
description="Sync with MyAnimeList"
|
description="Sync with MyAnimeList"
|
||||||
customIcon={<Image source={require('../../assets/rating-icons/mal-icon.png')} style={{ width: 20, height: 20, borderRadius: 4 }} resizeMode="contain" />}
|
customIcon={<Image source={require('../../assets/rating-icons/mal-icon.png')} style={{ width: 20, height: 20, borderRadius: 4 }} resizeMode="contain" />}
|
||||||
renderControl={() => <ChevronRight />}
|
renderControl={() => <ChevronRight />}
|
||||||
onPress={() => navigation.navigate('MalSettings')}
|
onPress={() => navigation.navigate('MalSettings')}
|
||||||
isLast
|
isLast
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</SettingsCard>
|
</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';
|
import axios from 'axios';
|
||||||
|
|
||||||
const MAPPING_PREFIX = 'mal_map_';
|
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 = {
|
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> => {
|
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.
|
// 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.
|
// 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';
|
const isGenericTitle = !normalizedTitle || normalizedTitle === 'anime' || normalizedTitle === 'movie';
|
||||||
|
|
||||||
if (isGenericTitle) {
|
const seasonNumber = season || 1;
|
||||||
// If we have an offline mapping, we can still try it below,
|
const cacheKey = getTitleCacheKey(cleanTitle, type, seasonNumber);
|
||||||
// but we MUST skip the fuzzy search logic at the end.
|
const legacyCacheKey = getLegacyTitleCacheKey(cleanTitle, type);
|
||||||
if (!imdbId) return null;
|
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)
|
// 1. Try ARM + Jikan Sync (Most accurate for perfect season/episode matching)
|
||||||
if (imdbId && type === 'series' && releaseDate) {
|
if (imdbId && type === 'series' && releaseDate) {
|
||||||
try {
|
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. Try IMDb ID mapping when it is likely to be accurate, or when title is generic.
|
||||||
|
if (imdbId && (type === 'movie' || seasonNumber <= 1 || isGenericTitle)) {
|
||||||
// 2. Check Cache for Title
|
const idFromImdb = await MalSync.getMalIdFromImdb(imdbId);
|
||||||
const cleanTitle = title.trim();
|
if (idFromImdb) return idFromImdb;
|
||||||
const cacheKey = `${MAPPING_PREFIX}${cleanTitle}_${type}_${season || 1}`;
|
}
|
||||||
const cachedId = mmkvStorage.getNumber(cacheKey);
|
|
||||||
if (cachedId) return cachedId;
|
|
||||||
|
|
||||||
// 3. Search MAL (Skip if generic title)
|
// 3. Search MAL (Skip if generic title)
|
||||||
if (isGenericTitle) return null;
|
if (isGenericTitle) return null;
|
||||||
|
|
@ -120,6 +131,7 @@ export const MalSync = {
|
||||||
|
|
||||||
// Save to cache
|
// Save to cache
|
||||||
mmkvStorage.setNumber(cacheKey, bestMatch.id);
|
mmkvStorage.setNumber(cacheKey, bestMatch.id);
|
||||||
|
mmkvStorage.setNumber(legacyCacheKey, bestMatch.id);
|
||||||
return bestMatch.id;
|
return bestMatch.id;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -290,8 +302,10 @@ export const MalSync = {
|
||||||
|
|
||||||
for (const item of allItems) {
|
for (const item of allItems) {
|
||||||
const type = item.node.media_type === 'movie' ? 'movie' : 'series';
|
const type = item.node.media_type === 'movie' ? 'movie' : 'series';
|
||||||
const cacheKey = `${MAPPING_PREFIX}${item.node.title.trim()}_${type}`;
|
const title = item.node.title.trim();
|
||||||
mmkvStorage.setNumber(cacheKey, item.node.id);
|
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.`);
|
console.log(`[MalSync] Synced ${allItems.length} items to mapping cache.`);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -304,9 +318,11 @@ export const MalSync = {
|
||||||
/**
|
/**
|
||||||
* Manually map an ID if auto-detection fails
|
* Manually map an ID if auto-detection fails
|
||||||
*/
|
*/
|
||||||
setMapping: (title: string, malId: number, type: 'movie' | 'series' = 'series') => {
|
setMapping: (title: string, malId: number, type: 'movie' | 'series' = 'series', season: number = 1) => {
|
||||||
const cacheKey = `${MAPPING_PREFIX}${title.trim()}_${type}`;
|
const cleanTitle = title.trim();
|
||||||
mmkvStorage.setNumber(cacheKey, malId);
|
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)
|
// Execution timeout (1 minute)
|
||||||
const PLUGIN_TIMEOUT_MS = 60000;
|
const PLUGIN_TIMEOUT_MS = 60000;
|
||||||
const functionName = params.functionName || 'getStreams';
|
const functionName = params.functionName || 'getStreams';
|
||||||
|
|
@ -1829,4 +1828,4 @@ class LocalScraperService {
|
||||||
|
|
||||||
export const localScraperService = LocalScraperService.getInstance();
|
export const localScraperService = LocalScraperService.getInstance();
|
||||||
export const pluginService = localScraperService; // Alias for UI consistency
|
export const pluginService = localScraperService; // Alias for UI consistency
|
||||||
export default localScraperService;
|
export default localScraperService;
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ class WatchedService {
|
||||||
const malToken = MalAuth.getToken();
|
const malToken = MalAuth.getToken();
|
||||||
if (malToken) {
|
if (malToken) {
|
||||||
MalSync.scrobbleEpisode(
|
MalSync.scrobbleEpisode(
|
||||||
'Movie',
|
imdbId,
|
||||||
1,
|
1,
|
||||||
1,
|
1,
|
||||||
'movie',
|
'movie',
|
||||||
|
|
@ -93,7 +93,8 @@ class WatchedService {
|
||||||
season: number,
|
season: number,
|
||||||
episode: number,
|
episode: number,
|
||||||
watchedAt: Date = new Date(),
|
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 }> {
|
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||||
try {
|
try {
|
||||||
logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`);
|
logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`);
|
||||||
|
|
@ -132,7 +133,7 @@ class WatchedService {
|
||||||
// Strategy 2: Offline Mapping Fallback
|
// Strategy 2: Offline Mapping Fallback
|
||||||
if (!synced) {
|
if (!synced) {
|
||||||
MalSync.scrobbleEpisode(
|
MalSync.scrobbleEpisode(
|
||||||
'Anime', // Title fallback
|
showTitle || showImdbId || 'Anime',
|
||||||
episode,
|
episode,
|
||||||
0,
|
0,
|
||||||
'series',
|
'series',
|
||||||
|
|
@ -411,10 +412,6 @@ class WatchedService {
|
||||||
showImdbId,
|
showImdbId,
|
||||||
season
|
season
|
||||||
);
|
);
|
||||||
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
|
|
||||||
showImdbId,
|
|
||||||
season
|
|
||||||
);
|
|
||||||
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
|
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue