feat: implement robust MyAnimeList integration with offline mapping and library UI

This commit is contained in:
paregi12 2026-01-17 12:56:31 +05:30
parent 50c1f36413
commit 7a09c46ccb
13 changed files with 763 additions and 499 deletions

View file

@ -0,0 +1,261 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TextInput,
ScrollView,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
import { MalApiService } from '../../services/mal/MalApi';
import { MalListStatus, MalAnimeNode } from '../../types/mal';
import { useToast } from '../../contexts/ToastContext';
interface MalEditModalProps {
visible: boolean;
onClose: () => void;
anime: MalAnimeNode;
onUpdateSuccess: () => void;
}
export const MalEditModal: React.FC<MalEditModalProps> = ({
visible,
onClose,
anime,
onUpdateSuccess,
}) => {
const { currentTheme } = useTheme();
const { showSuccess, showError } = useToast();
const [status, setStatus] = useState<MalListStatus>(anime.list_status.status);
const [episodes, setEpisodes] = useState(anime.list_status.num_episodes_watched.toString());
const [score, setScore] = useState(anime.list_status.score.toString());
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
if (visible) {
setStatus(anime.list_status.status);
setEpisodes(anime.list_status.num_episodes_watched.toString());
setScore(anime.list_status.score.toString());
}
}, [visible, anime]);
const handleUpdate = async () => {
setIsUpdating(true);
try {
const epNum = parseInt(episodes, 10) || 0;
let scoreNum = parseInt(score, 10) || 0;
// Validation: MAL scores must be between 0 and 10
scoreNum = Math.max(0, Math.min(10, scoreNum));
await MalApiService.updateStatus(anime.node.id, status, epNum, scoreNum);
showSuccess('Updated', `${anime.node.title} status updated on MAL`);
onUpdateSuccess();
onClose();
} catch (error) {
showError('Update Failed', 'Could not update MAL status');
} finally {
setIsUpdating(false);
}
};
const statusOptions: { label: string; value: MalListStatus }[] = [
{ label: 'Watching', value: 'watching' },
{ label: 'Completed', value: 'completed' },
{ label: 'On Hold', value: 'on_hold' },
{ label: 'Dropped', value: 'dropped' },
{ label: 'Plan to Watch', value: 'plan_to_watch' },
];
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
<View style={[styles.modalContent, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1}>
Edit {anime.node.title}
</Text>
<TouchableOpacity onPress={onClose}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.mediumEmphasis} />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>Status</Text>
<View style={styles.statusGrid}>
{statusOptions.map((option) => (
<TouchableOpacity
key={option.value}
style={[
styles.statusChip,
{ borderColor: currentTheme.colors.border },
status === option.value && {
backgroundColor: currentTheme.colors.primary,
borderColor: currentTheme.colors.primary
}
]}
onPress={() => setStatus(option.value)}
>
<Text style={[
styles.statusText,
{ color: currentTheme.colors.highEmphasis },
status === option.value && { color: 'white' }
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
<View style={styles.inputRow}>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>
Episodes ({anime.node.num_episodes || '?'})
</Text>
<TextInput
style={[styles.input, {
color: currentTheme.colors.highEmphasis,
borderColor: currentTheme.colors.border,
backgroundColor: currentTheme.colors.elevation1
}]}
value={episodes}
onChangeText={setEpisodes}
keyboardType="numeric"
placeholder="0"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>Score (0-10)</Text>
<TextInput
style={[styles.input, {
color: currentTheme.colors.highEmphasis,
borderColor: currentTheme.colors.border,
backgroundColor: currentTheme.colors.elevation1
}]}
value={score}
onChangeText={setScore}
keyboardType="numeric"
placeholder="0"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
/>
</View>
</View>
<TouchableOpacity
style={[styles.updateButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleUpdate}
disabled={isUpdating}
>
{isUpdating ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.updateButtonText}>Update MAL</Text>
)}
</TouchableOpacity>
</ScrollView>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
container: {
width: '100%',
maxWidth: 400,
},
modalContent: {
borderRadius: 16,
padding: 20,
maxHeight: '90%',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 18,
fontWeight: '700',
flex: 1,
marginRight: 10,
},
label: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
marginTop: 12,
},
statusGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
statusChip: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
marginBottom: 4,
},
statusText: {
fontSize: 13,
fontWeight: '500',
},
inputRow: {
flexDirection: 'row',
gap: 16,
marginTop: 8,
},
inputGroup: {
flex: 1,
},
input: {
height: 44,
borderRadius: 8,
borderWidth: 1,
paddingHorizontal: 12,
fontSize: 16,
},
updateButton: {
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginTop: 24,
marginBottom: 10,
},
updateButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '700',
},
});

View file

@ -105,8 +105,6 @@ interface HeroSectionProps {
dynamicBackgroundColor?: string;
handleBack: () => void;
tmdbId?: number | null;
malId?: number | null;
onMalPress?: () => void;
}
// Ultra-optimized ActionButtons Component - minimal re-renders
@ -129,9 +127,7 @@ const ActionButtons = memo(({
isInWatchlist,
isInCollection,
onToggleWatchlist,
onToggleCollection,
malId,
onMalPress
onToggleCollection
}: {
handleShowStreams: () => void;
toggleLibrary: () => void;
@ -152,8 +148,6 @@ const ActionButtons = memo(({
isInCollection?: boolean;
onToggleWatchlist?: () => void;
onToggleCollection?: () => void;
malId?: number | null;
onMalPress?: () => void;
}) => {
const { currentTheme } = useTheme();
const { t } = useTranslation();
@ -344,10 +338,9 @@ const ActionButtons = memo(({
// Count additional buttons (AI Chat removed - now in top right corner)
const hasTraktCollection = isAuthenticated;
const hasRatings = type === 'series';
const hasMal = !!malId;
// Count additional buttons (AI Chat removed - now in top right corner)
const additionalButtonCount = (hasTraktCollection ? 1 : 0) + (hasRatings ? 1 : 0) + (hasMal ? 1 : 0);
const additionalButtonCount = (hasTraktCollection ? 1 : 0) + (hasRatings ? 1 : 0);
return (
<Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}>
@ -460,33 +453,6 @@ const ActionButtons = memo(({
/>
</TouchableOpacity>
)}
{/* MAL Button */}
{hasMal && (
<TouchableOpacity
style={[styles.iconButton, isTablet && styles.tabletIconButton, styles.singleRowIconButton]}
onPress={onMalPress}
activeOpacity={0.85}
>
{Platform.OS === 'ios' ? (
GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp
style={styles.blurBackgroundRound}
glassEffectStyle="regular"
/>
) : (
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
)
) : (
<View style={styles.androidFallbackBlurRound} />
)}
<Image
source={require('../../../assets/rating-icons/mal-icon.png')}
style={{ width: isTablet ? 28 : 24, height: isTablet ? 28 : 24, borderRadius: isTablet ? 14 : 12 }}
resizeMode="contain"
/>
</TouchableOpacity>
)}
</View>
</Animated.View>
);
@ -891,8 +857,6 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
dynamicBackgroundColor,
handleBack,
tmdbId,
malId,
onMalPress,
// Trakt integration props
isAuthenticated,
isInWatchlist,
@ -1916,27 +1880,25 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
{/* Optimized Action Buttons */}
<ActionButtons
handleShowStreams={handleShowStreams}
toggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
type={type}
id={id}
navigation={navigation}
playButtonText={playButtonText}
animatedStyle={buttonsAnimatedStyle}
isWatched={isWatched}
watchProgress={watchProgress}
groupedEpisodes={groupedEpisodes}
metadata={metadata}
settings={settings}
// Trakt integration props
isAuthenticated={isAuthenticated}
isInWatchlist={isInWatchlist}
isInCollection={isInCollection}
onToggleWatchlist={onToggleWatchlist}
onToggleCollection={onToggleCollection}
malId={malId}
onMalPress={onMalPress}
/>
toggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
type={type}
id={id}
navigation={navigation}
playButtonText={playButtonText}
animatedStyle={buttonsAnimatedStyle}
isWatched={isWatched}
watchProgress={watchProgress}
groupedEpisodes={groupedEpisodes}
metadata={metadata}
settings={settings}
// Trakt integration props
isAuthenticated={isAuthenticated}
isInWatchlist={isInWatchlist}
isInCollection={isInCollection}
onToggleWatchlist={onToggleWatchlist}
onToggleCollection={onToggleCollection}
/>
</View>
</LinearGradient>
</Animated.View>

View file

@ -1,260 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { Modal, View, Text, StyleSheet, TouchableOpacity, ScrollView, ActivityIndicator, Image } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { MalApiService } from '../../services/mal/MalApi';
import { MalSync } from '../../services/mal/MalSync';
import { MalListStatus } from '../../types/mal';
import { useTheme } from '../../contexts/ThemeContext';
interface MalScoreModalProps {
visible: boolean;
onClose: () => void;
malId: number;
animeTitle: string;
initialStatus?: MalListStatus;
initialScore?: number;
initialEpisodes?: number;
// Season support props
seasons?: number[];
currentSeason?: number;
imdbId?: string;
type?: 'movie' | 'series';
}
const STATUS_OPTIONS: { label: string; value: MalListStatus }[] = [
{ label: 'Watching', value: 'watching' },
{ label: 'Completed', value: 'completed' },
{ label: 'On Hold', value: 'on_hold' },
{ label: 'Dropped', value: 'dropped' },
{ label: 'Plan to Watch', value: 'plan_to_watch' },
];
export const MalScoreModal: React.FC<MalScoreModalProps> = ({
visible,
onClose,
malId,
animeTitle,
initialStatus = 'watching',
initialScore = 0,
initialEpisodes = 0,
seasons = [],
currentSeason = 1,
imdbId,
type = 'series'
}) => {
const { currentTheme } = useTheme();
// State for season management
const [selectedSeason, setSelectedSeason] = useState(currentSeason);
const [activeMalId, setActiveMalId] = useState(malId);
const [fetchingData, setFetchingData] = useState(false);
// Form State
const [status, setStatus] = useState<MalListStatus>(initialStatus);
const [score, setScore] = useState(initialScore);
const [episodes, setEpisodes] = useState(initialEpisodes);
const [totalEpisodes, setTotalEpisodes] = useState(0);
const [loading, setLoading] = useState(false);
// Fetch data when season changes (only for series with multiple seasons)
useEffect(() => {
const loadSeasonData = async () => {
setFetchingData(true);
// Reset active ID to prevent writing to the wrong season if fetch fails
setActiveMalId(0);
try {
// 1. Resolve MAL ID for this season
let resolvedId = malId;
if (type === 'series' && (seasons.length > 1 || selectedSeason !== currentSeason)) {
resolvedId = await MalSync.getMalId(animeTitle, type, undefined, selectedSeason, imdbId) || 0;
}
if (resolvedId) {
setActiveMalId(resolvedId);
// 2. Fetch user status for this ID
const data = await MalApiService.getMyListStatus(resolvedId);
if (data.list_status) {
setStatus(data.list_status.status);
setScore(data.list_status.score);
setEpisodes(data.list_status.num_episodes_watched);
} else {
// Default if not in list
setStatus('plan_to_watch');
setScore(0);
setEpisodes(0);
}
setTotalEpisodes(data.num_episodes || 0);
} else {
console.warn('Could not resolve MAL ID for season', selectedSeason);
}
} catch (e) {
console.error('Failed to load season data', e);
} finally {
setFetchingData(false);
}
};
loadSeasonData();
}, [selectedSeason, type, animeTitle, imdbId, malId]);
const handleSave = async () => {
setLoading(true);
try {
await MalApiService.updateStatus(activeMalId, status, episodes, score);
onClose();
} catch (e) {
console.error('Failed to update MAL status', e);
} finally {
setLoading(false);
}
};
return (
<Modal visible={visible} transparent animationType="fade">
<View style={styles.overlay}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.elevation2 || '#1E1E1E' }]}>
<View style={styles.header}>
<Image
source={require('../../../assets/rating-icons/mal-icon.png')}
style={styles.logo}
resizeMode="contain"
/>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>{animeTitle}</Text>
</View>
{/* Season Selector */}
{type === 'series' && seasons.length > 1 && (
<View style={styles.seasonContainer}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis, marginTop: 0 }]}>Season</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.seasonScroll}>
{seasons.sort((a, b) => a - b).map((s) => (
<TouchableOpacity
key={s}
style={[
styles.seasonChip,
selectedSeason === s && { backgroundColor: currentTheme.colors.primary },
{ borderColor: currentTheme.colors.border }
]}
onPress={() => setSelectedSeason(s)}
>
<Text style={[styles.chipText, selectedSeason === s && { color: '#fff', fontWeight: 'bold' }]}>
Season {s}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
)}
{fetchingData ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
</View>
) : (
<ScrollView>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis }]}>Status</Text>
<View style={styles.optionsRow}>
{STATUS_OPTIONS.map((opt) => (
<TouchableOpacity
key={opt.value}
style={[
styles.chip,
status === opt.value && { backgroundColor: currentTheme.colors.primary },
{ borderColor: currentTheme.colors.border }
]}
onPress={() => setStatus(opt.value)}
>
<Text style={[styles.chipText, status === opt.value && { color: '#fff' }]}>{opt.label}</Text>
</TouchableOpacity>
))}
</View>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis }]}>Episodes Watched</Text>
<View style={styles.episodeRow}>
<TouchableOpacity
style={[styles.roundButton, { borderColor: currentTheme.colors.border }]}
onPress={() => setEpisodes(Math.max(0, episodes - 1))}
>
<MaterialIcons name="remove" size={20} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
<View style={styles.episodeDisplay}>
<Text style={[styles.episodeCount, { color: currentTheme.colors.highEmphasis }]}>{episodes}</Text>
{totalEpisodes > 0 && (
<Text style={[styles.totalEpisodes, { color: currentTheme.colors.mediumEmphasis }]}> / {totalEpisodes}</Text>
)}
</View>
<TouchableOpacity
style={[styles.roundButton, { borderColor: currentTheme.colors.border }]}
onPress={() => setEpisodes(totalEpisodes > 0 ? Math.min(totalEpisodes, episodes + 1) : episodes + 1)}
>
<MaterialIcons name="add" size={20} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
</View>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis }]}>Score</Text>
<View style={styles.optionsRow}>
{[...Array(11).keys()].map((s) => (
<TouchableOpacity
key={s}
style={[
styles.scoreChip,
score === s && { backgroundColor: '#F5C518', borderColor: '#F5C518' },
{ borderColor: currentTheme.colors.border }
]}
onPress={() => setScore(s)}
>
<Text style={[styles.chipText, score === s && { color: '#000', fontWeight: 'bold' }]}>{s === 0 ? '-' : s}</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
)}
<View style={styles.footer}>
<TouchableOpacity style={styles.cancelButton} onPress={onClose}>
<Text style={{ color: currentTheme.colors.mediumEmphasis }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: currentTheme.colors.primary, opacity: (loading || fetchingData || !activeMalId) ? 0.6 : 1 }]}
onPress={handleSave}
disabled={loading || fetchingData || !activeMalId}
>
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.saveButtonText}>Save</Text>}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.8)', justifyContent: 'center', padding: 20 },
container: { borderRadius: 16, padding: 20, maxHeight: '85%' },
header: { flexDirection: 'row', alignItems: 'center', marginBottom: 20 },
logo: { width: 32, height: 32, marginRight: 12, borderRadius: 8 },
title: { fontSize: 18, fontWeight: 'bold', flex: 1 },
sectionTitle: { fontSize: 14, fontWeight: '600', marginTop: 16, marginBottom: 8 },
optionsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, borderWidth: 1, marginBottom: 4 },
scoreChip: { width: 40, height: 40, borderRadius: 20, borderWidth: 1, justifyContent: 'center', alignItems: 'center', marginBottom: 4 },
chipText: { fontSize: 12, fontWeight: '500' },
footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: 16, marginTop: 24 },
cancelButton: { padding: 12 },
saveButton: { paddingHorizontal: 24, paddingVertical: 12, borderRadius: 24, minWidth: 100, alignItems: 'center' },
saveButtonText: { color: '#fff', fontWeight: 'bold' },
seasonContainer: { marginBottom: 8 },
seasonScroll: { paddingVertical: 4, gap: 8 },
seasonChip: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, borderWidth: 1, marginRight: 8 },
loadingContainer: { height: 200, justifyContent: 'center', alignItems: 'center' },
episodeRow: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 8 },
roundButton: { width: 40, height: 40, borderRadius: 20, borderWidth: 1, justifyContent: 'center', alignItems: 'center' },
episodeDisplay: { flexDirection: 'row', alignItems: 'baseline', minWidth: 80, justifyContent: 'center' },
episodeCount: { fontSize: 20, fontWeight: 'bold' },
totalEpisodes: { fontSize: 14, marginLeft: 2 },
});

View file

@ -56,6 +56,7 @@ import HomeScreenSettings from '../screens/HomeScreenSettings';
import HeroCatalogsScreen from '../screens/HeroCatalogsScreen';
import TraktSettingsScreen from '../screens/TraktSettingsScreen';
import MalSettingsScreen from '../screens/MalSettingsScreen';
import MalLibraryScreen from '../screens/MalLibraryScreen';
import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
import ThemeScreen from '../screens/ThemeScreen';
import OnboardingScreen from '../screens/OnboardingScreen';
@ -187,6 +188,7 @@ export type RootStackParamList = {
HeroCatalogs: undefined;
TraktSettings: undefined;
MalSettings: undefined;
MalLibrary: undefined;
PlayerSettings: undefined;
ThemeSettings: undefined;
ScraperSettings: undefined;
@ -1582,6 +1584,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
},
}}
/>
<Stack.Screen
name="MalLibrary"
component={MalLibraryScreen}
options={{
animation: Platform.OS === 'android' ? 'default' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="PlayerSettings"
component={PlayerSettingsScreen}

View file

@ -965,9 +965,7 @@ const LibraryScreen = () => {
return;
}
if (filterType === 'mal') {
setShowTraktContent(false);
setFilter('mal');
loadMalList();
navigation.navigate('MalLibrary');
return;
}
setShowTraktContent(false);

View file

@ -0,0 +1,312 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
FlatList,
ActivityIndicator,
SafeAreaView,
StatusBar,
Platform,
Dimensions,
RefreshControl,
ScrollView,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image';
import { MalApiService } from '../services/mal/MalApi';
import { MalAnimeNode, MalListStatus } from '../types/mal';
import { useTheme } from '../contexts/ThemeContext';
import { useTranslation } from 'react-i18next';
import { mappingService } from '../services/MappingService';
import { logger } from '../utils/logger';
import { MalEditModal } from '../components/mal/MalEditModal';
const { width } = Dimensions.get('window');
const ITEM_WIDTH = width * 0.35;
const ITEM_HEIGHT = ITEM_WIDTH * 1.5;
const MalLibraryScreen: React.FC = () => {
const { t } = useTranslation();
const navigation = useNavigation<any>();
const { currentTheme } = useTheme();
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [groupedList, setGroupedList] = useState<Record<MalListStatus, MalAnimeNode[]>>({
watching: [],
completed: [],
on_hold: [],
dropped: [],
plan_to_watch: [],
});
const [selectedAnime, setSelectedAnime] = useState<MalAnimeNode | null>(null);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const fetchMalList = useCallback(async () => {
try {
setIsLoading(true);
let allItems: MalAnimeNode[] = [];
let offset = 0;
let hasMore = true;
while (hasMore && offset < 1000) {
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;
}
}
const grouped: Record<MalListStatus, MalAnimeNode[]> = {
watching: [],
completed: [],
on_hold: [],
dropped: [],
plan_to_watch: [],
};
allItems.forEach(item => {
const status = item.list_status.status;
if (grouped[status]) {
grouped[status].push(item);
}
});
setGroupedList(grouped);
} catch (error) {
logger.error('[MalLibrary] Failed to fetch list', error);
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
fetchMalList();
}, [fetchMalList]);
const handleRefresh = () => {
setIsRefreshing(true);
fetchMalList();
};
const handleItemPress = async (item: MalAnimeNode) => {
// Requirement 8: Resolve correct Cinemata / TMDB / IMDb ID
const malId = item.node.id;
// Check offline mapping first (reverse lookup)
await mappingService.init();
const imdbId = mappingService.getImdbIdFromMalId(malId);
if (imdbId) {
navigation.navigate('Metadata', {
id: imdbId,
type: item.node.media_type === 'movie' ? 'movie' : 'series'
});
} else {
// Fallback: Navigate to Search with the title if ID mapping is missing
logger.warn(`[MalLibrary] Could not resolve IMDb ID for MAL:${malId}. Falling back to Search.`);
navigation.navigate('Search', { query: item.node.title });
}
};
const renderAnimeItem = ({ item }: { item: MalAnimeNode }) => (
<TouchableOpacity
style={styles.animeItem}
onPress={() => handleItemPress(item)}
activeOpacity={0.7}
>
<FastImage
source={{ uri: item.node.main_picture?.medium }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
/>
<View style={styles.badgeContainer}>
<View style={[styles.episodeBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.episodeText}>
{item.list_status.num_episodes_watched} / {item.node.num_episodes || '?'}
</Text>
</View>
</View>
<Text style={[styles.animeTitle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={2}>
{item.node.title}
</Text>
{item.list_status.score > 0 && (
<View style={styles.scoreRow}>
<MaterialIcons name="star" size={12} color="#FFD700" />
<Text style={[styles.scoreText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.list_status.score}
</Text>
</View>
)}
{/* Requirement 5: Manual update button */}
<TouchableOpacity
style={styles.editButton}
onPress={() => {
setSelectedAnime(item);
setIsEditModalVisible(true);
}}
>
<MaterialIcons name="edit" size={16} color={currentTheme.colors.white} />
</TouchableOpacity>
</TouchableOpacity>
);
const renderSection = (status: MalListStatus, title: string, icon: string) => {
const data = groupedList[status];
if (data.length === 0) return null;
return (
<View style={styles.sectionContainer}>
<View style={styles.sectionHeader}>
<MaterialIcons name={icon as any} size={20} color={currentTheme.colors.primary} style={{ marginRight: 8 }} />
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
{title} ({data.length})
</Text>
</View>
<FlatList
data={data}
renderItem={renderAnimeItem}
keyExtractor={item => item.node.id.toString()}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.carouselContent}
snapToInterval={ITEM_WIDTH + 12}
decelerationRate="fast"
/>
</View>
);
};
return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
MyAnimeList
</Text>
{/* Requirement 6: Manual Sync Button */}
<TouchableOpacity onPress={handleRefresh} style={styles.syncButton} disabled={isLoading}>
{isLoading ? (
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
) : (
<MaterialIcons name="sync" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
</View>
{!isLoading || isRefreshing ? (
<ScrollView
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} tintColor={currentTheme.colors.primary} />
}
contentContainerStyle={{ paddingBottom: 40 }}
>
{renderSection('watching', 'Watching', 'play-circle-outline')}
{renderSection('plan_to_watch', 'Plan to Watch', 'bookmark-outline')}
{renderSection('completed', 'Completed', 'check-circle-outline')}
{renderSection('on_hold', 'On Hold', 'pause-circle-outline')}
{renderSection('dropped', 'Dropped', 'highlight-off')}
</ScrollView>
) : (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
</View>
)}
{selectedAnime && (
<MalEditModal
visible={isEditModalVisible}
anime={selectedAnime}
onClose={() => {
setIsEditModalVisible(false);
setSelectedAnime(null);
}}
onUpdateSuccess={fetchMalList}
/>
)}
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
justifyContent: 'space-between'
},
backButton: { padding: 4 },
headerTitle: { fontSize: 20, fontWeight: '700', flex: 1, marginLeft: 16 },
syncButton: { padding: 4 },
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
sectionContainer: { marginVertical: 12 },
sectionHeader: { paddingHorizontal: 16, marginBottom: 8 },
sectionTitle: { fontSize: 18, fontWeight: '700' },
carouselContent: { paddingHorizontal: 10 },
animeItem: {
width: ITEM_WIDTH,
marginHorizontal: 6,
marginBottom: 10,
},
poster: {
width: ITEM_WIDTH,
height: ITEM_HEIGHT,
borderRadius: 8,
backgroundColor: '#333',
},
badgeContainer: {
position: 'absolute',
top: 6,
left: 6,
},
episodeBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
episodeText: {
color: 'white',
fontSize: 10,
fontWeight: '700',
},
animeTitle: {
fontSize: 12,
fontWeight: '600',
marginTop: 6,
lineHeight: 16,
},
scoreRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
},
scoreText: {
fontSize: 11,
marginLeft: 4,
},
editButton: {
position: 'absolute',
top: 6,
right: 6,
backgroundColor: 'rgba(0,0,0,0.6)',
padding: 6,
borderRadius: 15,
}
});
export default MalLibraryScreen;

View file

@ -17,6 +17,7 @@ import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import FastImage from '@d11/react-native-fast-image';
import { MalAuth } from '../services/mal/MalAuth';
import { MalApiService } from '../services/mal/MalApi';
import { MalSync } from '../services/mal/MalSync';
import { mmkvStorage } from '../services/mmkvStorage';
import { MalUser } from '../types/mal';
import { useTheme } from '../contexts/ThemeContext';
@ -37,6 +38,7 @@ const MalSettingsScreen: React.FC = () => {
const [syncEnabled, setSyncEnabled] = useState(mmkvStorage.getBoolean('mal_enabled') ?? true);
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(mmkvStorage.getBoolean('mal_auto_update') ?? true);
const [autoAddEnabled, setAutoAddEnabled] = useState(mmkvStorage.getBoolean('mal_auto_add') ?? true);
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
@ -132,6 +134,11 @@ const MalSettingsScreen: React.FC = () => {
mmkvStorage.setBoolean('mal_auto_update', val);
};
const toggleAutoAdd = (val: boolean) => {
setAutoAddEnabled(val);
mmkvStorage.setBoolean('mal_auto_add', val);
};
return (
<SafeAreaView style={[
styles.container,
@ -193,6 +200,22 @@ const MalSettingsScreen: React.FC = () => {
>
<Text style={styles.buttonText}>Sign Out</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary, marginTop: 12 }]}
onPress={() => {
setIsLoading(true);
MalSync.syncMalToLibrary().then(() => {
setIsLoading(false);
openAlert('Sync Complete', 'MAL data has been refreshed.');
});
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="sync" size={20} color="white" style={{ marginRight: 8 }} />
<Text style={styles.buttonText}>Sync Now</Text>
</View>
</TouchableOpacity>
</View>
) : (
<View style={styles.signInContainer}>
@ -207,12 +230,6 @@ const MalSettingsScreen: React.FC = () => {
<Text style={[styles.signInDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Sync your watch history and manage your anime list.
</Text>
<View style={[styles.noteContainer, { backgroundColor: currentTheme.colors.primary + '15', borderColor: currentTheme.colors.primary + '30' }]}>
<MaterialIcons name="info-outline" size={18} color={currentTheme.colors.primary} />
<Text style={[styles.noteText, { color: currentTheme.colors.highEmphasis }]}>
MAL sync only works with the <Text style={{ fontWeight: 'bold' }}>AnimeKitsu</Text> catalog items.
</Text>
</View>
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleSignIn}
@ -234,10 +251,10 @@ const MalSettingsScreen: React.FC = () => {
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
Enable Sync
Enable MAL Sync
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Sync watch status to MyAnimeList
Global switch to enable or disable all MyAnimeList features.
</Text>
</View>
<Switch
@ -256,7 +273,7 @@ const MalSettingsScreen: React.FC = () => {
Auto Episode Update
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Automatically update episode progress when watching
Automatically update your progress on MAL when you finish watching an episode (>=90% completion).
</Text>
</View>
<Switch
@ -267,6 +284,25 @@ const MalSettingsScreen: React.FC = () => {
/>
</View>
</View>
<View style={styles.settingItem}>
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
Auto Add Anime
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
If an anime is not in your MAL list, it will be added automatically when you start watching.
</Text>
</View>
<Switch
value={autoAddEnabled}
onValueChange={toggleAutoAdd}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={autoAddEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
/>
</View>
</View>
</View>
</View>
)}

View file

@ -85,10 +85,6 @@ const MemoizedRatingsSection = memo(RatingsSection);
const MemoizedCommentsSection = memo(CommentsSection);
const MemoizedCastDetailsModal = memo(CastDetailsModal);
import { MalAuth } from '../services/mal/MalAuth';
import { MalSync } from '../services/mal/MalSync';
import { MalScoreModal } from '../components/metadata/MalScoreModal';
// ... other imports
const MetadataScreen: React.FC = () => {
@ -129,73 +125,6 @@ const MetadataScreen: React.FC = () => {
loadingCollection,
} = useMetadata({ id, type, addonId });
const [malModalVisible, setMalModalVisible] = useState(false);
const [malId, setMalId] = useState<number | null>(null);
const isMalAuthenticated = !!MalAuth.getToken();
useEffect(() => {
// STRICT MODE: Only enable MAL features if the content source is explicitly Anime (MAL/Kitsu)
// This prevents "fuzzy match" errors where Cinemeta shows get mapped to random anime or wrong seasons.
const isAnimeSource = id && (id.startsWith('mal:') || id.startsWith('kitsu:') || id.includes(':mal:') || id.includes(':kitsu:'));
if (isMalAuthenticated && metadata?.name && isAnimeSource) {
// If it's a MAL source, extract ID directly
if (id.startsWith('mal:')) {
const directId = parseInt(id.split(':')[1], 10);
if (!isNaN(directId)) {
setMalId(directId);
return;
}
}
// Otherwise resolve (e.g. Kitsu -> MAL)
MalSync.getMalId(metadata.name, Object.keys(groupedEpisodes).length > 0 ? 'series' : 'movie')
.then(id => setMalId(id));
} else {
setMalId(null);
}
}, [isMalAuthenticated, metadata, groupedEpisodes, id]);
// Log route parameters for debugging
React.useEffect(() => {
console.log('🔍 [MetadataScreen] Route params:', { id, type, episodeId, addonId });
}, [id, type, episodeId, addonId]);
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
// Determine device type based on width
const getDeviceType = useCallback(() => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
}, [deviceWidth]);
const deviceType = getDeviceType();
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
const isLargeScreen = isTablet || isLargeTablet || isTV;
// Enhanced spacing and padding for production sections
const horizontalPadding = useMemo(() => {
switch (deviceType) {
case 'tv':
return 32;
case 'largeTablet':
return 28;
case 'tablet':
return 24;
default:
return 16; // phone
}
}, [deviceType]);
// Optimized state management - reduced state variables
const [isContentReady, setIsContentReady] = useState(false);
const [showCastModal, setShowCastModal] = useState(false);
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const [shouldLoadSecondaryData, setShouldLoadSecondaryData] = useState(false);
const [isScreenFocused, setIsScreenFocused] = useState(true);
@ -1029,8 +958,6 @@ const MetadataScreen: React.FC = () => {
dynamicBackgroundColor={dynamicBackgroundColor}
handleBack={handleBack}
tmdbId={tmdbId}
malId={malId}
onMalPress={() => setMalModalVisible(true)}
/>
{/* Main Content - Optimized */}
@ -1458,19 +1385,6 @@ const MetadataScreen: React.FC = () => {
isSpoilerRevealed={selectedComment ? revealedSpoilers.has(selectedComment.id.toString()) : false}
onSpoilerPress={() => selectedComment && handleSpoilerPress(selectedComment)}
/>
{malId && (
<MalScoreModal
visible={malModalVisible}
onClose={() => setMalModalVisible(false)}
malId={malId}
animeTitle={metadata?.name || ''}
seasons={Object.keys(groupedEpisodes).map(Number)}
currentSeason={selectedSeason}
imdbId={imdbId || undefined}
type={Object.keys(groupedEpisodes).length > 0 ? 'series' : type as 'movie' | 'series'}
/>
)}
</AnimatedSafeAreaView>
</Animated.View>
);

View file

@ -10,7 +10,7 @@ import {
ScrollView,
Platform,
} from 'react-native';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { useNavigation, useFocusEffect, useRoute } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons';
@ -63,6 +63,7 @@ const SearchScreen = () => {
const { t } = useTranslation();
const { settings } = useSettings();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const route = useRoute<any>();
const { addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection, isInWatchlist, isInCollection } = useTraktContext();
const { showSuccess, showInfo } = useToast();
const [query, setQuery] = useState('');
@ -469,6 +470,13 @@ const SearchScreen = () => {
useFocusEffect(
useCallback(() => {
isMounted.current = true;
// Check for route query param
if (route.params?.query && route.params.query !== query) {
setQuery(route.params.query);
// The query effect will trigger debouncedSearch automatically
}
return () => {
isMounted.current = false;
if (liveSearchHandle.current) {
@ -477,7 +485,7 @@ const SearchScreen = () => {
}
debouncedSearch.cancel();
};
}, [debouncedSearch])
}, [debouncedSearch, route.params?.query])
);
const performLiveSearch = async (searchQuery: string) => {

View file

@ -25,6 +25,7 @@ interface Mappings {
class MappingService {
private mappings: Mappings = {};
private imdbIndex: { [imdbId: string]: string[] } = {}; // Maps IMDb ID to array of AniList IDs
private malIndex: { [malId: number]: string } = {}; // Maps MAL ID to AniList ID
private isInitialized = false;
/**
@ -63,7 +64,9 @@ class MappingService {
*/
private buildIndex() {
this.imdbIndex = {};
this.malIndex = {};
for (const [anilistId, entry] of Object.entries(this.mappings)) {
// IMDb Index
if (entry.imdb_id) {
const imdbIds = Array.isArray(entry.imdb_id) ? entry.imdb_id : [entry.imdb_id];
for (const id of imdbIds) {
@ -73,6 +76,14 @@ class MappingService {
this.imdbIndex[id].push(anilistId);
}
}
// MAL Index
if (entry.mal_id) {
const malIds = Array.isArray(entry.mal_id) ? entry.mal_id : [entry.mal_id];
for (const id of malIds) {
this.malIndex[id] = anilistId;
}
}
}
}
@ -85,16 +96,11 @@ class MappingService {
console.warn('MappingService not initialized. Call init() first.');
}
// Since we don't have a direct index for MAL IDs yet, we iterate (inefficient but works for now)
// Optimization: In a real app, we should build a malIndex similar to imdbIndex during init()
for (const entry of Object.values(this.mappings)) {
if (entry.mal_id) {
const malIds = Array.isArray(entry.mal_id) ? entry.mal_id : [entry.mal_id];
if (malIds.includes(malId)) {
if (entry.imdb_id) {
return Array.isArray(entry.imdb_id) ? entry.imdb_id[0] : entry.imdb_id;
}
}
const anilistId = this.malIndex[malId];
if (anilistId) {
const entry = this.mappings[anilistId];
if (entry && entry.imdb_id) {
return Array.isArray(entry.imdb_id) ? entry.imdb_id[0] : entry.imdb_id;
}
}
return null;

View file

@ -42,6 +42,17 @@ export const MalSync = {
* 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);
@ -59,7 +70,9 @@ export const MalSync = {
const cachedId = mmkvStorage.getNumber(cacheKey);
if (cachedId) return cachedId;
// 3. Search MAL
// 3. Search MAL (Skip if generic title)
if (isGenericTitle) return null;
try {
let searchQuery = cleanTitle;
// For Season 2+, explicitly search for that season
@ -117,9 +130,48 @@ export const MalSync = {
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
@ -152,17 +204,27 @@ export const MalSync = {
*/
syncMalToLibrary: async () => {
try {
const list = await MalApiService.getUserList();
const watching = list.data.filter(item => item.list_status.status === 'watching' || item.list_status.status === 'plan_to_watch');
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 watching) {
// Try to find in local catalogs to get a proper StreamingContent object
// This is complex because we need to map MAL -> Stremio/TMDB.
// For now, we'll just cache the mapping for future use.
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);

View file

@ -1186,64 +1186,9 @@ class StremioService {
async getStreams(type: string, id: string, callback?: StreamCallback): Promise<void> {
await this.ensureInitialized();
// Resolve MAL/Kitsu IDs to IMDb/TMDB for better stream compatibility
let activeId = id;
let resolvedTmdbId: string | null = null;
if (id.startsWith('mal:') || id.includes(':mal:')) {
try {
// Parse MAL ID and potential season/episode
let malId: number | null = null;
let s: number | undefined;
let e: number | undefined;
const parts = id.split(':');
// Handle mal:123
if (id.startsWith('mal:')) {
malId = parseInt(parts[1], 10);
// simple mal:id usually implies movie or main series entry, assume s1e1 if not present?
// MetadataScreen typically passes raw id for movies, or constructs episode string for series
}
// Handle series:mal:123:1:1
else if (id.includes(':mal:')) {
// series:mal:123:1:1
if (parts[1] === 'mal') malId = parseInt(parts[2], 10);
if (parts.length >= 5) {
s = parseInt(parts[3], 10);
e = parseInt(parts[4], 10);
}
}
if (malId) {
logger.log(`[getStreams] Resolving MAL ID ${malId} to IMDb/TMDB...`);
const { imdbId, season: malSeason } = await MalSync.getIdsFromMalId(malId);
if (imdbId) {
const finalSeason = s || malSeason || 1;
const finalEpisode = e || 1;
// 1. Set ID for Stremio Addons (Torrentio/Debrid searchers prefer IMDb)
if (type === 'series') {
// Ensure proper IMDb format: tt12345:1:1
activeId = `${imdbId}:${finalSeason}:${finalEpisode}`;
} else {
activeId = imdbId;
}
logger.log(`[getStreams] Resolved -> Stremio ID: ${activeId}`);
// 2. Set ID for Local Scrapers (They prefer TMDB)
const tmdbIdNum = await TMDBService.getInstance().findTMDBIdByIMDB(imdbId);
if (tmdbIdNum) {
resolvedTmdbId = tmdbIdNum.toString();
logger.log(`[getStreams] Resolved -> TMDB ID: ${resolvedTmdbId}`);
}
}
}
} catch (err) {
logger.error('[getStreams] Failed to resolve MAL ID:', err);
}
}
const addons = this.getInstalledAddons();
// Check if local scrapers are enabled and execute them first

View file

@ -3,7 +3,7 @@ import { storageService } from './storageService';
import { mmkvStorage } from './mmkvStorage';
import { logger } from '../utils/logger';
import { MalSync } from './mal/MalSync';
import { MalAuthService } from './mal/MalAuth';
import { MalAuth } from './mal/MalAuth';
import { mappingService } from './MappingService';
/**
@ -39,6 +39,9 @@ class WatchedService {
try {
logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`);
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
// Sync to Trakt
if (isTraktAuth) {
syncedToTrakt = await this.traktService.addToWatchedMovies(imdbId, watchedAt);
@ -46,8 +49,8 @@ class WatchedService {
}
// Sync to MAL
const isMalAuth = await MalAuthService.isAuthenticated();
if (isMalAuth) {
const malToken = MalAuth.getToken();
if (malToken) {
MalSync.scrobbleEpisode(
'Movie',
1,
@ -86,6 +89,9 @@ class WatchedService {
try {
logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`);
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
// Sync to Trakt
if (isTraktAuth) {
syncedToTrakt = await this.traktService.addToWatchedEpisodes(
@ -98,11 +104,8 @@ class WatchedService {
}
// Sync to MAL
const isMalAuth = await MalAuthService.isAuthenticated();
if (isMalAuth && showImdbId) {
// We need the title for scrobbleEpisode (as fallback),
// but getMalId will now prioritize the IMDb mapping.
// We'll use a placeholder title or try to find it if possible.
const malToken = MalAuth.getToken();
if (malToken && showImdbId) {
MalSync.scrobbleEpisode(
'Anime', // Title fallback
episode,