diff --git a/src/components/mal/MalEditModal.tsx b/src/components/mal/MalEditModal.tsx index 9bec6c07..5e41b89f 100644 --- a/src/components/mal/MalEditModal.tsx +++ b/src/components/mal/MalEditModal.tsx @@ -10,6 +10,8 @@ import { KeyboardAvoidingView, Platform, ActivityIndicator, + Alert, + Switch, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; @@ -36,13 +38,16 @@ export const MalEditModal: React.FC = ({ const [status, setStatus] = useState(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 [isRewatching, setIsRewatching] = useState(anime.list_status.is_rewatching || false); const [isUpdating, setIsUpdating] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); useEffect(() => { if (visible) { setStatus(anime.list_status.status); setEpisodes(anime.list_status.num_episodes_watched.toString()); setScore(anime.list_status.score.toString()); + setIsRewatching(anime.list_status.is_rewatching || false); } }, [visible, anime]); @@ -55,7 +60,7 @@ export const MalEditModal: React.FC = ({ // 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); + await MalApiService.updateStatus(anime.node.id, status, epNum, scoreNum, isRewatching); showSuccess('Updated', `${anime.node.title} status updated on MAL`); onUpdateSuccess(); @@ -67,6 +72,33 @@ export const MalEditModal: React.FC = ({ } }; + const handleRemove = async () => { + Alert.alert( + 'Remove from List', + `Are you sure you want to remove ${anime.node.title} from your MyAnimeList?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: async () => { + setIsRemoving(true); + try { + await MalApiService.removeFromList(anime.node.id); + showSuccess('Removed', `${anime.node.title} removed from MAL`); + onUpdateSuccess(); + onClose(); + } catch (error) { + showError('Remove Failed', 'Could not remove from MAL'); + } finally { + setIsRemoving(false); + } + } + } + ] + ); + }; + const statusOptions: { label: string; value: MalListStatus }[] = [ { label: 'Watching', value: 'watching' }, { label: 'Completed', value: 'completed' }, @@ -160,10 +192,25 @@ export const MalEditModal: React.FC = ({ + + + Rewatching + + Mark this if you are watching the series again. + + + + + {isUpdating ? ( @@ -171,6 +218,20 @@ export const MalEditModal: React.FC = ({ Update MAL )} + + + {isRemoving ? ( + + ) : ( + + Remove from List + + )} + @@ -245,6 +306,21 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, fontSize: 16, }, + rewatchRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 20, + paddingVertical: 8, + }, + rewatchTextContainer: { + flex: 1, + marginRight: 16, + }, + rewatchDescription: { + fontSize: 12, + marginTop: 2, + }, updateButton: { height: 48, borderRadius: 24, @@ -258,4 +334,17 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '700', }, + removeButton: { + height: 48, + borderRadius: 24, + justifyContent: 'center', + alignItems: 'center', + marginTop: 8, + marginBottom: 20, + borderWidth: 1, + }, + removeButtonText: { + fontSize: 16, + fontWeight: '600', + }, }); diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 0356dee3..79b0d2ff 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -1487,7 +1487,7 @@ const LibraryScreen = () => { const currentOffset = isLoadMore ? malOffset : 0; setMalLoading(true); try { - const response = await MalApiService.getUserList(undefined, currentOffset); + const response = await MalApiService.getUserList(undefined, currentOffset, 100); if (isLoadMore) { setMalMalList(prev => [...prev, ...response.data]); } else { diff --git a/src/screens/MalLibraryScreen.tsx b/src/screens/MalLibraryScreen.tsx index 622dbfcd..6acc5bfb 100644 --- a/src/screens/MalLibraryScreen.tsx +++ b/src/screens/MalLibraryScreen.tsx @@ -187,61 +187,68 @@ const MalLibraryScreen: React.FC = () => { }; return ( - - - - navigation.goBack()} style={styles.backButton}> - - - - MyAnimeList - - {/* Requirement 6: Manual Sync Button */} - - {isLoading ? ( - - ) : ( - - )} - - - - {!isLoading || isRefreshing ? ( - - } - 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')} - - ) : ( - - + + + + + navigation.goBack()} style={styles.backButton}> + + + + MyAnimeList + + {/* Requirement 6: Manual Sync Button */} + + {isLoading ? ( + + ) : ( + + )} + - )} - {selectedAnime && ( - { - setIsEditModalVisible(false); - setSelectedAnime(null); - }} - onUpdateSuccess={fetchMalList} - /> - )} - + {!isLoading || isRefreshing ? ( + + } + 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')} + + ) : ( + + + + )} + + {selectedAnime && ( + { + setIsEditModalVisible(false); + setSelectedAnime(null); + }} + onUpdateSuccess={fetchMalList} + /> + )} + + ); }; const styles = StyleSheet.create({ container: { flex: 1 }, + safeArea: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 }, header: { flexDirection: 'row', alignItems: 'center', diff --git a/src/screens/MalSettingsScreen.tsx b/src/screens/MalSettingsScreen.tsx index f15b0e29..79f412b3 100644 --- a/src/screens/MalSettingsScreen.tsx +++ b/src/screens/MalSettingsScreen.tsx @@ -39,6 +39,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 [autoLibrarySyncEnabled, setAutoLibrarySyncEnabled] = useState(mmkvStorage.getBoolean('mal_auto_sync_to_library') ?? false); const [alertVisible, setAlertVisible] = useState(false); const [alertTitle, setAlertTitle] = useState(''); @@ -139,6 +140,11 @@ const MalSettingsScreen: React.FC = () => { mmkvStorage.setBoolean('mal_auto_add', val); }; + const toggleAutoLibrarySync = (val: boolean) => { + setAutoLibrarySyncEnabled(val); + mmkvStorage.setBoolean('mal_auto_sync_to_library', val); + }; + return ( { {userProfile.name} - - ID: {userProfile.id} - + + + + ID: {userProfile.id} + + + {userProfile.location && ( + + + + {userProfile.location} + + + )} + {userProfile.birthday && ( + + + + {userProfile.birthday} + + + )} - - - Sign Out - - { - setIsLoading(true); - 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); - } - }} - > - - - Sync Now + {userProfile.anime_statistics && ( + + + + + {userProfile.anime_statistics.num_items} + + Total + + + + {userProfile.anime_statistics.num_days_watched.toFixed(1)} + + Days + + + + {userProfile.anime_statistics.mean_score.toFixed(1)} + + Mean + + + + + + + Watching + + {userProfile.anime_statistics.num_items_watching} + + + + + Completed + + {userProfile.anime_statistics.num_items_completed} + + + + + On Hold + + {userProfile.anime_statistics.num_items_on_hold} + + + + + Dropped + + {userProfile.anime_statistics.num_items_dropped} + + + - + )} + + + { + setIsLoading(true); + 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); + } + }} + > + + Sync + + + + Sign Out + + ) : ( @@ -311,6 +392,25 @@ const MalSettingsScreen: React.FC = () => { /> + + + + + + Auto-Sync to Library + + + Automatically add items from your MAL 'Watching' list to your Nuvio Library. + + + + + )} @@ -371,7 +471,37 @@ const styles = StyleSheet.create({ avatarText: { fontSize: 24, color: 'white', fontWeight: 'bold' }, profileInfo: { marginLeft: 16, flex: 1 }, profileName: { fontSize: 18, fontWeight: '600' }, - profileUsername: { fontSize: 14 }, + profileDetailRow: { flexDirection: 'row', alignItems: 'center', marginTop: 2 }, + profileDetailText: { fontSize: 12, marginLeft: 4 }, + statsContainer: { marginTop: 20 }, + statsRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 16 }, + statBox: { alignItems: 'center', flex: 1 }, + statValue: { fontSize: 18, fontWeight: 'bold' }, + statLabel: { fontSize: 12, marginTop: 2 }, + statGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + borderTopWidth: 1, + paddingTop: 16, + gap: 12 + }, + statGridItem: { + flexDirection: 'row', + alignItems: 'center', + width: '45%', + marginBottom: 8 + }, + statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8 }, + statGridLabel: { fontSize: 13, flex: 1 }, + statGridValue: { fontSize: 13, fontWeight: '600' }, + actionButtonsRow: { flexDirection: 'row', marginTop: 20 }, + smallButton: { + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, signOutButton: { marginTop: 20 }, settingsSection: { padding: 20 }, sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 16 }, diff --git a/src/services/mal/MalApi.ts b/src/services/mal/MalApi.ts index 32950288..c1667c5e 100644 --- a/src/services/mal/MalApi.ts +++ b/src/services/mal/MalApi.ts @@ -39,6 +39,7 @@ export const MalApiService = { limit, offset, sort: 'list_updated_at', + nsfw: true // Ensure all content is returned }, }); return response.data; @@ -64,11 +65,13 @@ export const MalApiService = { malId: number, status: MalListStatus, episode: number, - score?: number + score?: number, + isRewatching?: boolean ) => { const data: any = { status, num_watched_episodes: episode, + is_rewatching: isRewatching || false }; if (score && score > 0) data.score = score; @@ -77,6 +80,10 @@ export const MalApiService = { }); }, + removeFromList: async (malId: number) => { + return api.delete(`/anime/${malId}/my_list_status`); + }, + getAnimeDetails: async (malId: number) => { try { const response = await api.get(`/anime/${malId}`, { @@ -91,7 +98,11 @@ export const MalApiService = { getUserInfo: async (): Promise => { try { - const response = await api.get('/users/@me'); + const response = await api.get('/users/@me', { + params: { + fields: 'id,name,picture,gender,birthday,location,joined_at,anime_statistics,time_zone' + } + }); return response.data; } catch (error) { console.error('Failed to get user info', error); diff --git a/src/services/mal/MalSync.ts b/src/services/mal/MalSync.ts index 0a61efb7..8e83b78d 100644 --- a/src/services/mal/MalSync.ts +++ b/src/services/mal/MalSync.ts @@ -349,6 +349,12 @@ export const MalSync = { mmkvStorage.setNumber(getLegacyTitleCacheKey(title, type), item.node.id); } console.log(`[MalSync] Synced ${allItems.length} items to mapping cache.`); + + // If auto-sync to library is enabled, also add 'watching' items to Nuvio Library + if (mmkvStorage.getBoolean('mal_auto_sync_to_library') ?? false) { + await MalSync.syncMalWatchingToLibrary(); + } + return true; } catch (e) { console.error('syncMalToLibrary failed', e); @@ -356,6 +362,49 @@ export const MalSync = { } }, + /** + * Automatically adds MAL 'watching' items to the Nuvio Library + */ + syncMalWatchingToLibrary: async () => { + try { + logger.log('[MalSync] Auto-syncing MAL watching items to library...'); + + const response = await MalApiService.getUserList('watching', 0, 50); + if (!response.data || response.data.length === 0) return; + + for (const item of response.data) { + const malId = item.node.id; + const { imdbId } = await MalSync.getIdsFromMalId(malId); + + if (imdbId) { + const type = item.node.media_type === 'movie' ? 'movie' : 'series'; + + // Check if already in library to avoid redundant calls + const currentLibrary = await catalogService.getLibraryItems(); + const exists = currentLibrary.some(l => l.id === imdbId); + + if (!exists) { + logger.log(`[MalSync] Auto-adding to library: ${item.node.title} (${imdbId})`); + + await catalogService.addToLibrary({ + id: imdbId, + type: type, + name: item.node.title, + poster: item.node.main_picture?.large || item.node.main_picture?.medium || '', + posterShape: 'poster', + year: item.node.start_season?.year, + description: '', + genres: [], + inLibrary: true, + }); + } + } + } + } catch (e) { + logger.error('[MalSync] syncMalWatchingToLibrary failed:', e); + } + }, + /** * Manually map an ID if auto-detection fails */ diff --git a/src/types/mal.ts b/src/types/mal.ts index 165240f0..621916d4 100644 --- a/src/types/mal.ts +++ b/src/types/mal.ts @@ -9,8 +9,28 @@ export interface MalUser { id: number; name: string; picture?: string; + gender?: string; + birthday?: string; location?: string; joined_at?: string; + time_zone?: string; + anime_statistics?: { + num_items_watching: number; + num_items_completed: number; + num_items_on_hold: number; + num_items_dropped: number; + num_items_plan_to_watch: number; + num_items: number; + num_days_watched: number; + num_days_watching: number; + num_days_completed: number; + num_days_on_hold: number; + num_days_dropped: number; + num_days: number; + num_episodes: number; + num_times_rewatched: number; + mean_score: number; + }; } export interface MalAnime {