mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-11 04:21:42 +00:00
added episode/season mark as watched feature syncing locally/trakt.
This commit is contained in:
parent
60cdf9fe86
commit
d876b7618c
7 changed files with 1218 additions and 8 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { useEffect, useState, useRef, useCallback, useMemo, memo } from 'react';
|
import React, { useEffect, useState, useRef, useCallback, useMemo, memo } from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Modal, Pressable } from 'react-native';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
import FastImage from '@d11/react-native-fast-image';
|
import FastImage from '@d11/react-native-fast-image';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
@ -12,6 +13,7 @@ import { storageService } from '../../services/storageService';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated';
|
import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated';
|
||||||
import { TraktService } from '../../services/traktService';
|
import { TraktService } from '../../services/traktService';
|
||||||
|
import { watchedService } from '../../services/watchedService';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { mmkvStorage } from '../../services/mmkvStorage';
|
import { mmkvStorage } from '../../services/mmkvStorage';
|
||||||
|
|
||||||
|
|
@ -31,6 +33,7 @@ interface SeriesContentProps {
|
||||||
onSelectEpisode: (episode: Episode) => void;
|
onSelectEpisode: (episode: Episode) => void;
|
||||||
groupedEpisodes?: { [seasonNumber: number]: Episode[] };
|
groupedEpisodes?: { [seasonNumber: number]: Episode[] };
|
||||||
metadata?: { poster?: string; id?: string };
|
metadata?: { poster?: string; id?: string };
|
||||||
|
imdbId?: string; // IMDb ID for Trakt sync
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add placeholder constant at the top
|
// Add placeholder constant at the top
|
||||||
|
|
@ -46,7 +49,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
||||||
onSeasonChange,
|
onSeasonChange,
|
||||||
onSelectEpisode,
|
onSelectEpisode,
|
||||||
groupedEpisodes = {},
|
groupedEpisodes = {},
|
||||||
metadata
|
metadata,
|
||||||
|
imdbId
|
||||||
}) => {
|
}) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
|
@ -180,6 +184,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
||||||
const [posterViewVisible, setPosterViewVisible] = useState(true);
|
const [posterViewVisible, setPosterViewVisible] = useState(true);
|
||||||
const [textViewVisible, setTextViewVisible] = useState(false);
|
const [textViewVisible, setTextViewVisible] = useState(false);
|
||||||
|
|
||||||
|
// Episode action menu state
|
||||||
|
const [episodeActionMenuVisible, setEpisodeActionMenuVisible] = useState(false);
|
||||||
|
const [selectedEpisodeForAction, setSelectedEpisodeForAction] = useState<Episode | null>(null);
|
||||||
|
const [markingAsWatched, setMarkingAsWatched] = useState(false);
|
||||||
|
|
||||||
// Add refs for the scroll views
|
// Add refs for the scroll views
|
||||||
const seasonScrollViewRef = useRef<ScrollView | null>(null);
|
const seasonScrollViewRef = useRef<ScrollView | null>(null);
|
||||||
const episodeScrollViewRef = useRef<FlashListRef<Episode>>(null);
|
const episodeScrollViewRef = useRef<FlashListRef<Episode>>(null);
|
||||||
|
|
@ -517,6 +526,207 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
||||||
return rating ?? null;
|
return rating ?? null;
|
||||||
}, [imdbRatingsMap]);
|
}, [imdbRatingsMap]);
|
||||||
|
|
||||||
|
// Handle long press on episode to show action menu
|
||||||
|
const handleEpisodeLongPress = useCallback((episode: Episode) => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
setSelectedEpisodeForAction(episode);
|
||||||
|
setEpisodeActionMenuVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check if an episode is watched (>= 85% progress)
|
||||||
|
const isEpisodeWatched = useCallback((episode: Episode): boolean => {
|
||||||
|
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
|
||||||
|
const progress = episodeProgress[episodeId];
|
||||||
|
if (!progress) return false;
|
||||||
|
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||||
|
return progressPercent >= 85;
|
||||||
|
}, [episodeProgress, metadata?.id]);
|
||||||
|
|
||||||
|
// Mark episode as watched
|
||||||
|
const handleMarkAsWatched = useCallback(async () => {
|
||||||
|
if (!selectedEpisodeForAction || !metadata?.id) return;
|
||||||
|
|
||||||
|
const episode = selectedEpisodeForAction; // Capture for closure
|
||||||
|
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
|
||||||
|
|
||||||
|
// 1. Optimistic UI Update
|
||||||
|
setEpisodeProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
[episodeId]: { currentTime: 1, duration: 1, lastUpdated: Date.now() } // 100% progress
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. Instant Feedback
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
setEpisodeActionMenuVisible(false);
|
||||||
|
setSelectedEpisodeForAction(null);
|
||||||
|
|
||||||
|
// 3. Background Async Operation
|
||||||
|
const showImdbId = imdbId || metadata.id;
|
||||||
|
try {
|
||||||
|
const result = await watchedService.markEpisodeAsWatched(
|
||||||
|
showImdbId,
|
||||||
|
metadata.id,
|
||||||
|
episode.season_number,
|
||||||
|
episode.episode_number
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
logger.log(`[SeriesContent] Mark as watched result:`, result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[SeriesContent] Error marking episode as watched:', error);
|
||||||
|
// Ideally revert state here, but simple error logging is often enough for non-critical non-transactional actions
|
||||||
|
loadEpisodesProgress(); // Reload to revert to source of truth
|
||||||
|
}
|
||||||
|
}, [selectedEpisodeForAction, metadata?.id, imdbId]);
|
||||||
|
|
||||||
|
// Mark episode as unwatched
|
||||||
|
const handleMarkAsUnwatched = useCallback(async () => {
|
||||||
|
if (!selectedEpisodeForAction || !metadata?.id) return;
|
||||||
|
|
||||||
|
const episode = selectedEpisodeForAction;
|
||||||
|
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
|
||||||
|
|
||||||
|
// 1. Optimistic UI Update - Remove from progress map
|
||||||
|
setEpisodeProgress(prev => {
|
||||||
|
const newState = { ...prev };
|
||||||
|
delete newState[episodeId];
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Instant Feedback
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
setEpisodeActionMenuVisible(false);
|
||||||
|
setSelectedEpisodeForAction(null);
|
||||||
|
|
||||||
|
// 3. Background Async Operation
|
||||||
|
const showImdbId = imdbId || metadata.id;
|
||||||
|
try {
|
||||||
|
const result = await watchedService.unmarkEpisodeAsWatched(
|
||||||
|
showImdbId,
|
||||||
|
metadata.id,
|
||||||
|
episode.season_number,
|
||||||
|
episode.episode_number
|
||||||
|
);
|
||||||
|
|
||||||
|
loadEpisodesProgress(); // Sync with source of truth
|
||||||
|
logger.log(`[SeriesContent] Unmark watched result:`, result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[SeriesContent] Error unmarking episode as watched:', error);
|
||||||
|
loadEpisodesProgress(); // Revert
|
||||||
|
}
|
||||||
|
}, [selectedEpisodeForAction, metadata?.id, imdbId]);
|
||||||
|
|
||||||
|
// Mark entire season as watched
|
||||||
|
const handleMarkSeasonAsWatched = useCallback(async () => {
|
||||||
|
if (!metadata?.id) return;
|
||||||
|
|
||||||
|
// Capture values
|
||||||
|
const currentSeason = selectedSeason;
|
||||||
|
const seasonEpisodes = groupedEpisodes[currentSeason] || [];
|
||||||
|
const episodeNumbers = seasonEpisodes.map(ep => ep.episode_number);
|
||||||
|
|
||||||
|
// 1. Optimistic UI Update
|
||||||
|
setEpisodeProgress(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
seasonEpisodes.forEach(ep => {
|
||||||
|
const id = ep.stremioId || `${metadata.id}:${ep.season_number}:${ep.episode_number}`;
|
||||||
|
next[id] = { currentTime: 1, duration: 1, lastUpdated: Date.now() };
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Instant Feedback
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
setEpisodeActionMenuVisible(false);
|
||||||
|
setSelectedEpisodeForAction(null);
|
||||||
|
|
||||||
|
// 3. Background Async Operation
|
||||||
|
const showImdbId = imdbId || metadata.id;
|
||||||
|
try {
|
||||||
|
const result = await watchedService.markSeasonAsWatched(
|
||||||
|
showImdbId,
|
||||||
|
metadata.id,
|
||||||
|
currentSeason,
|
||||||
|
episodeNumbers
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-sync with source of truth
|
||||||
|
loadEpisodesProgress();
|
||||||
|
|
||||||
|
logger.log(`[SeriesContent] Mark season as watched result:`, result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[SeriesContent] Error marking season as watched:', error);
|
||||||
|
loadEpisodesProgress(); // Revert
|
||||||
|
}
|
||||||
|
}, [metadata?.id, imdbId, selectedSeason, groupedEpisodes]);
|
||||||
|
|
||||||
|
// Check if entire season is watched
|
||||||
|
const isSeasonWatched = useCallback((): boolean => {
|
||||||
|
const seasonEpisodes = groupedEpisodes[selectedSeason] || [];
|
||||||
|
if (seasonEpisodes.length === 0) return false;
|
||||||
|
|
||||||
|
return seasonEpisodes.every(ep => {
|
||||||
|
const episodeId = ep.stremioId || `${metadata?.id}:${ep.season_number}:${ep.episode_number}`;
|
||||||
|
const progress = episodeProgress[episodeId];
|
||||||
|
if (!progress) return false;
|
||||||
|
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||||
|
return progressPercent >= 85;
|
||||||
|
});
|
||||||
|
}, [groupedEpisodes, selectedSeason, episodeProgress, metadata?.id]);
|
||||||
|
|
||||||
|
// Unmark entire season as watched
|
||||||
|
const handleMarkSeasonAsUnwatched = useCallback(async () => {
|
||||||
|
if (!metadata?.id) return;
|
||||||
|
|
||||||
|
// Capture values
|
||||||
|
const currentSeason = selectedSeason;
|
||||||
|
const seasonEpisodes = groupedEpisodes[currentSeason] || [];
|
||||||
|
const episodeNumbers = seasonEpisodes.map(ep => ep.episode_number);
|
||||||
|
|
||||||
|
// 1. Optimistic UI Update - Remove all episodes of season from progress
|
||||||
|
setEpisodeProgress(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
seasonEpisodes.forEach(ep => {
|
||||||
|
const id = ep.stremioId || `${metadata.id}:${ep.season_number}:${ep.episode_number}`;
|
||||||
|
delete next[id];
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Instant Feedback
|
||||||
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
setEpisodeActionMenuVisible(false);
|
||||||
|
setSelectedEpisodeForAction(null);
|
||||||
|
|
||||||
|
// 3. Background Async Operation
|
||||||
|
const showImdbId = imdbId || metadata.id;
|
||||||
|
try {
|
||||||
|
const result = await watchedService.unmarkSeasonAsWatched(
|
||||||
|
showImdbId,
|
||||||
|
metadata.id,
|
||||||
|
currentSeason,
|
||||||
|
episodeNumbers
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-sync
|
||||||
|
loadEpisodesProgress();
|
||||||
|
|
||||||
|
logger.log(`[SeriesContent] Unmark season as watched result:`, result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[SeriesContent] Error unmarking season as watched:', error);
|
||||||
|
loadEpisodesProgress(); // Revert
|
||||||
|
}
|
||||||
|
}, [metadata?.id, imdbId, selectedSeason, groupedEpisodes]);
|
||||||
|
|
||||||
|
// Close action menu
|
||||||
|
const closeEpisodeActionMenu = useCallback(() => {
|
||||||
|
setEpisodeActionMenuVisible(false);
|
||||||
|
setSelectedEpisodeForAction(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (loadingSeasons) {
|
if (loadingSeasons) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centeredContainer}>
|
<View style={styles.centeredContainer}>
|
||||||
|
|
@ -826,6 +1036,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
onPress={() => onSelectEpisode(episode)}
|
onPress={() => onSelectEpisode(episode)}
|
||||||
|
onLongPress={() => handleEpisodeLongPress(episode)}
|
||||||
|
delayLongPress={400}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={[
|
<View style={[
|
||||||
|
|
@ -1107,6 +1319,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
onPress={() => onSelectEpisode(episode)}
|
onPress={() => onSelectEpisode(episode)}
|
||||||
|
onLongPress={() => handleEpisodeLongPress(episode)}
|
||||||
|
delayLongPress={400}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
{/* Solid outline replaces gradient border */}
|
{/* Solid outline replaces gradient border */}
|
||||||
|
|
@ -1438,6 +1652,205 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Episode Action Menu Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={episodeActionMenuVisible}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeEpisodeActionMenu}
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.85)', // Darker overlay
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 20,
|
||||||
|
}}
|
||||||
|
onPress={closeEpisodeActionMenu}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#1E1E1E', // Solid opaque dark background
|
||||||
|
borderRadius: isTV ? 20 : 16,
|
||||||
|
padding: isTV ? 24 : 20,
|
||||||
|
width: isTV ? 400 : isLargeTablet ? 360 : isTablet ? 320 : '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
alignSelf: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.1)', // Subtle border
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 10,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.51,
|
||||||
|
shadowRadius: 13.16,
|
||||||
|
elevation: 20,
|
||||||
|
}}
|
||||||
|
onPress={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={{ marginBottom: isTV ? 20 : 16 }}>
|
||||||
|
<Text style={{
|
||||||
|
color: '#FFFFFF', // High contrast text
|
||||||
|
fontSize: isTV ? 20 : 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
marginBottom: 4,
|
||||||
|
}}>
|
||||||
|
{selectedEpisodeForAction ? `S${selectedEpisodeForAction.season_number}E${selectedEpisodeForAction.episode_number}` : ''}
|
||||||
|
</Text>
|
||||||
|
<Text style={{
|
||||||
|
color: '#AAAAAA', // Medium emphasis text
|
||||||
|
fontSize: isTV ? 16 : 14,
|
||||||
|
}} numberOfLines={1} ellipsizeMode="tail">
|
||||||
|
{selectedEpisodeForAction?.name || ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<View style={{ gap: isTV ? 12 : 10 }}>
|
||||||
|
{/* Mark as Watched / Unwatched */}
|
||||||
|
{selectedEpisodeForAction && (
|
||||||
|
isEpisodeWatched(selectedEpisodeForAction) ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.08)', // Defined background
|
||||||
|
padding: isTV ? 16 : 14,
|
||||||
|
borderRadius: isTV ? 12 : 10,
|
||||||
|
opacity: markingAsWatched ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onPress={handleMarkAsUnwatched}
|
||||||
|
disabled={markingAsWatched}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="visibility-off"
|
||||||
|
size={isTV ? 24 : 22}
|
||||||
|
color="#FFFFFF"
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<Text style={{
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: isTV ? 16 : 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
{markingAsWatched ? 'Removing...' : 'Mark as Unwatched'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: currentTheme.colors.primary,
|
||||||
|
padding: isTV ? 16 : 14,
|
||||||
|
borderRadius: isTV ? 12 : 10,
|
||||||
|
opacity: markingAsWatched ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onPress={handleMarkAsWatched}
|
||||||
|
disabled={markingAsWatched}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="check-circle"
|
||||||
|
size={isTV ? 24 : 22}
|
||||||
|
color="#FFFFFF"
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<Text style={{
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: isTV ? 16 : 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
}}>
|
||||||
|
{markingAsWatched ? 'Marking...' : 'Mark as Watched'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mark Season as Watched / Unwatched */}
|
||||||
|
{isSeasonWatched() ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
padding: isTV ? 16 : 14,
|
||||||
|
borderRadius: isTV ? 12 : 10,
|
||||||
|
opacity: markingAsWatched ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onPress={handleMarkSeasonAsUnwatched}
|
||||||
|
disabled={markingAsWatched}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="playlist-remove"
|
||||||
|
size={isTV ? 24 : 22}
|
||||||
|
color="#FFFFFF"
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<Text style={{
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: isTV ? 16 : 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
flex: 1, // Allow text to take up space
|
||||||
|
}} numberOfLines={1}>
|
||||||
|
{markingAsWatched ? 'Removing...' : `Unmark Season ${selectedSeason}`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
padding: isTV ? 16 : 14,
|
||||||
|
borderRadius: isTV ? 12 : 10,
|
||||||
|
opacity: markingAsWatched ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
onPress={handleMarkSeasonAsWatched}
|
||||||
|
disabled={markingAsWatched}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="playlist-add-check"
|
||||||
|
size={isTV ? 24 : 22}
|
||||||
|
color="#FFFFFF"
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<Text style={{
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: isTV ? 16 : 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
flex: 1,
|
||||||
|
}} numberOfLines={1}>
|
||||||
|
{markingAsWatched ? 'Marking...' : `Mark Season ${selectedSeason}`}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: isTV ? 14 : 12,
|
||||||
|
marginTop: isTV ? 8 : 4,
|
||||||
|
}}
|
||||||
|
onPress={closeEpisodeActionMenu}
|
||||||
|
>
|
||||||
|
<Text style={{
|
||||||
|
color: '#999999',
|
||||||
|
fontSize: isTV ? 15 : 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
Cancel
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import { mmkvStorage } from '../services/mmkvStorage';
|
import { mmkvStorage } from '../services/mmkvStorage';
|
||||||
import * as Updates from 'expo-updates';
|
import * as Updates from 'expo-updates';
|
||||||
import { getDisplayedAppVersion } from '../utils/version';
|
import { getDisplayedAppVersion } from '../utils/version';
|
||||||
|
|
@ -23,6 +24,7 @@ export function useGithubMajorUpdate(): MajorUpdateData {
|
||||||
const [releaseUrl, setReleaseUrl] = useState<string | undefined>();
|
const [releaseUrl, setReleaseUrl] = useState<string | undefined>();
|
||||||
|
|
||||||
const check = useCallback(async () => {
|
const check = useCallback(async () => {
|
||||||
|
if (Platform.OS === 'ios') return;
|
||||||
try {
|
try {
|
||||||
// Always compare with Settings screen version
|
// Always compare with Settings screen version
|
||||||
const current = getDisplayedAppVersion() || Updates.runtimeVersion || '0.0.0';
|
const current = getDisplayedAppVersion() || Updates.runtimeVersion || '0.0.0';
|
||||||
|
|
|
||||||
|
|
@ -662,12 +662,16 @@ const AddonsScreen = () => {
|
||||||
const installedAddons = await stremioService.getInstalledAddonsAsync();
|
const installedAddons = await stremioService.getInstalledAddonsAsync();
|
||||||
|
|
||||||
// Filter out Torbox addons (managed via DebridIntegrationScreen)
|
// Filter out Torbox addons (managed via DebridIntegrationScreen)
|
||||||
|
// Filter out only the official Torbox integration addon (managed via DebridIntegrationScreen)
|
||||||
|
// but allow other addons (like Torrentio, MediaFusion) that may be configured with Torbox
|
||||||
const filteredAddons = installedAddons.filter(addon => {
|
const filteredAddons = installedAddons.filter(addon => {
|
||||||
const isTorboxAddon =
|
const isOfficialTorboxAddon =
|
||||||
addon.id?.includes('torbox') ||
|
addon.url?.includes('stremio.torbox.app') ||
|
||||||
addon.url?.includes('torbox') ||
|
(addon as any).transport?.includes('stremio.torbox.app') ||
|
||||||
(addon as any).transport?.includes('torbox');
|
// Check for ID but be careful not to catch others if possible, though ID usually comes from URL in stremioService
|
||||||
return !isTorboxAddon;
|
(addon.id?.includes('stremio.torbox.app'));
|
||||||
|
|
||||||
|
return !isOfficialTorboxAddon;
|
||||||
});
|
});
|
||||||
|
|
||||||
setAddons(filteredAddons as ExtendedManifest[]);
|
setAddons(filteredAddons as ExtendedManifest[]);
|
||||||
|
|
|
||||||
|
|
@ -1274,6 +1274,7 @@ const MetadataScreen: React.FC = () => {
|
||||||
onSelectEpisode={handleEpisodeSelect}
|
onSelectEpisode={handleEpisodeSelect}
|
||||||
groupedEpisodes={groupedEpisodes}
|
groupedEpisodes={groupedEpisodes}
|
||||||
metadata={metadata || undefined}
|
metadata={metadata || undefined}
|
||||||
|
imdbId={imdbId || undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
metadata && <MemoizedMovieContent metadata={metadata} />
|
metadata && <MemoizedMovieContent metadata={metadata} />
|
||||||
|
|
|
||||||
|
|
@ -1326,11 +1326,15 @@ export class TraktService {
|
||||||
try {
|
try {
|
||||||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||||
if (!traktId) {
|
if (!traktId) {
|
||||||
|
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.log(`[TraktService] Marking S${season}E${episode} as watched for show ${imdbId} (trakt: ${traktId})`);
|
||||||
|
|
||||||
|
// Use shows array with seasons/episodes structure per Trakt API docs
|
||||||
await this.apiRequest('/sync/history', 'POST', {
|
await this.apiRequest('/sync/history', 'POST', {
|
||||||
episodes: [
|
shows: [
|
||||||
{
|
{
|
||||||
ids: {
|
ids: {
|
||||||
trakt: traktId
|
trakt: traktId
|
||||||
|
|
@ -1349,6 +1353,7 @@ export class TraktService {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
logger.log(`[TraktService] Successfully marked S${season}E${episode} as watched`);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[TraktService] Failed to mark episode as watched:', error);
|
logger.error('[TraktService] Failed to mark episode as watched:', error);
|
||||||
|
|
@ -1356,6 +1361,194 @@ export class TraktService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an entire season as watched on Trakt
|
||||||
|
* @param imdbId - The IMDb ID of the show
|
||||||
|
* @param season - The season number to mark as watched
|
||||||
|
* @param watchedAt - Optional date when watched (defaults to now)
|
||||||
|
*/
|
||||||
|
public async markSeasonAsWatched(
|
||||||
|
imdbId: string,
|
||||||
|
season: number,
|
||||||
|
watchedAt: Date = new Date()
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||||
|
if (!traktId) {
|
||||||
|
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[TraktService] Marking entire season ${season} as watched for show ${imdbId} (trakt: ${traktId})`);
|
||||||
|
|
||||||
|
// Mark entire season - Trakt will mark all episodes in the season
|
||||||
|
await this.apiRequest('/sync/history', 'POST', {
|
||||||
|
shows: [
|
||||||
|
{
|
||||||
|
ids: {
|
||||||
|
trakt: traktId
|
||||||
|
},
|
||||||
|
seasons: [
|
||||||
|
{
|
||||||
|
number: season,
|
||||||
|
watched_at: watchedAt.toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
logger.log(`[TraktService] Successfully marked season ${season} as watched`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TraktService] Failed to mark season as watched:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark multiple episodes as watched on Trakt (batch operation)
|
||||||
|
* @param imdbId - The IMDb ID of the show
|
||||||
|
* @param episodes - Array of episodes to mark as watched
|
||||||
|
* @param watchedAt - Optional date when watched (defaults to now)
|
||||||
|
*/
|
||||||
|
public async markEpisodesAsWatched(
|
||||||
|
imdbId: string,
|
||||||
|
episodes: Array<{ season: number; episode: number }>,
|
||||||
|
watchedAt: Date = new Date()
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (episodes.length === 0) {
|
||||||
|
logger.warn('[TraktService] No episodes provided to mark as watched');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||||
|
if (!traktId) {
|
||||||
|
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[TraktService] Marking ${episodes.length} episodes as watched for show ${imdbId}`);
|
||||||
|
|
||||||
|
// Group episodes by season for the API call
|
||||||
|
const seasonMap = new Map<number, Array<{ number: number; watched_at: string }>>();
|
||||||
|
for (const ep of episodes) {
|
||||||
|
if (!seasonMap.has(ep.season)) {
|
||||||
|
seasonMap.set(ep.season, []);
|
||||||
|
}
|
||||||
|
seasonMap.get(ep.season)!.push({
|
||||||
|
number: ep.episode,
|
||||||
|
watched_at: watchedAt.toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasons = Array.from(seasonMap.entries()).map(([seasonNum, eps]) => ({
|
||||||
|
number: seasonNum,
|
||||||
|
episodes: eps
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.apiRequest('/sync/history', 'POST', {
|
||||||
|
shows: [
|
||||||
|
{
|
||||||
|
ids: {
|
||||||
|
trakt: traktId
|
||||||
|
},
|
||||||
|
seasons
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
logger.log(`[TraktService] Successfully marked ${episodes.length} episodes as watched`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TraktService] Failed to mark episodes as watched:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark entire show as watched on Trakt (all seasons and episodes)
|
||||||
|
* @param imdbId - The IMDb ID of the show
|
||||||
|
* @param watchedAt - Optional date when watched (defaults to now)
|
||||||
|
*/
|
||||||
|
public async markShowAsWatched(
|
||||||
|
imdbId: string,
|
||||||
|
watchedAt: Date = new Date()
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||||||
|
if (!traktId) {
|
||||||
|
logger.warn(`[TraktService] Could not find Trakt ID for show: ${imdbId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[TraktService] Marking entire show as watched: ${imdbId} (trakt: ${traktId})`);
|
||||||
|
|
||||||
|
// Mark entire show - Trakt will mark all episodes
|
||||||
|
await this.apiRequest('/sync/history', 'POST', {
|
||||||
|
shows: [
|
||||||
|
{
|
||||||
|
ids: {
|
||||||
|
trakt: traktId
|
||||||
|
},
|
||||||
|
watched_at: watchedAt.toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
logger.log(`[TraktService] Successfully marked entire show as watched`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TraktService] Failed to mark show as watched:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an entire season from watched history on Trakt
|
||||||
|
* @param imdbId - The IMDb ID of the show
|
||||||
|
* @param season - The season number to remove from history
|
||||||
|
*/
|
||||||
|
public async removeSeasonFromHistory(
|
||||||
|
imdbId: string,
|
||||||
|
season: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
logger.log(`[TraktService] Removing season ${season} from history for show: ${imdbId}`);
|
||||||
|
|
||||||
|
const fullImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||||||
|
|
||||||
|
const payload: TraktHistoryRemovePayload = {
|
||||||
|
shows: [
|
||||||
|
{
|
||||||
|
ids: {
|
||||||
|
imdb: fullImdbId
|
||||||
|
},
|
||||||
|
seasons: [
|
||||||
|
{
|
||||||
|
number: season
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.log(`[TraktService] Sending removeSeasonFromHistory payload:`, JSON.stringify(payload, null, 2));
|
||||||
|
|
||||||
|
const result = await this.removeFromHistory(payload);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
const success = result.deleted.episodes > 0;
|
||||||
|
logger.log(`[TraktService] Season removal success: ${success} (${result.deleted.episodes} episodes deleted)`);
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[TraktService] No result from removeSeasonFromHistory`);
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TraktService] Failed to remove season from history:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a movie is in user's watched history
|
* Check if a movie is in user's watched history
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
392
src/services/watchedService.ts
Normal file
392
src/services/watchedService.ts
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
import { TraktService } from './traktService';
|
||||||
|
import { storageService } from './storageService';
|
||||||
|
import { mmkvStorage } from './mmkvStorage';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WatchedService - Manages "watched" status for movies, episodes, and seasons.
|
||||||
|
* Handles both local storage and Trakt sync transparently.
|
||||||
|
*
|
||||||
|
* When Trakt is authenticated, it syncs to Trakt.
|
||||||
|
* When not authenticated, it stores locally.
|
||||||
|
*/
|
||||||
|
class WatchedService {
|
||||||
|
private static instance: WatchedService;
|
||||||
|
private traktService: TraktService;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.traktService = TraktService.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): WatchedService {
|
||||||
|
if (!WatchedService.instance) {
|
||||||
|
WatchedService.instance = new WatchedService();
|
||||||
|
}
|
||||||
|
return WatchedService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a movie as watched
|
||||||
|
* @param imdbId - The IMDb ID of the movie
|
||||||
|
* @param watchedAt - Optional date when watched
|
||||||
|
*/
|
||||||
|
public async markMovieAsWatched(
|
||||||
|
imdbId: string,
|
||||||
|
watchedAt: Date = new Date()
|
||||||
|
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||||
|
try {
|
||||||
|
logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`);
|
||||||
|
|
||||||
|
// Check if Trakt is authenticated
|
||||||
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||||
|
let syncedToTrakt = false;
|
||||||
|
|
||||||
|
if (isTraktAuth) {
|
||||||
|
// Sync to Trakt
|
||||||
|
syncedToTrakt = await this.traktService.addToWatchedMovies(imdbId, watchedAt);
|
||||||
|
logger.log(`[WatchedService] Trakt sync result for movie: ${syncedToTrakt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also store locally as "completed" (100% progress)
|
||||||
|
await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt);
|
||||||
|
|
||||||
|
return { success: true, syncedToTrakt };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[WatchedService] Failed to mark movie as watched:', error);
|
||||||
|
return { success: false, syncedToTrakt: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a single episode as watched
|
||||||
|
* @param showImdbId - The IMDb ID of the show
|
||||||
|
* @param showId - The Stremio ID of the show (for local storage)
|
||||||
|
* @param season - Season number
|
||||||
|
* @param episode - Episode number
|
||||||
|
* @param watchedAt - Optional date when watched
|
||||||
|
*/
|
||||||
|
public async markEpisodeAsWatched(
|
||||||
|
showImdbId: string,
|
||||||
|
showId: string,
|
||||||
|
season: number,
|
||||||
|
episode: number,
|
||||||
|
watchedAt: Date = new Date()
|
||||||
|
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||||
|
try {
|
||||||
|
logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`);
|
||||||
|
|
||||||
|
// Check if Trakt is authenticated
|
||||||
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||||
|
let syncedToTrakt = false;
|
||||||
|
|
||||||
|
if (isTraktAuth) {
|
||||||
|
// Sync to Trakt
|
||||||
|
syncedToTrakt = await this.traktService.addToWatchedEpisodes(
|
||||||
|
showImdbId,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
watchedAt
|
||||||
|
);
|
||||||
|
logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store locally as "completed"
|
||||||
|
const episodeId = `${showId}:${season}:${episode}`;
|
||||||
|
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
|
||||||
|
|
||||||
|
return { success: true, syncedToTrakt };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[WatchedService] Failed to mark episode as watched:', error);
|
||||||
|
return { success: false, syncedToTrakt: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark multiple episodes as watched (batch operation)
|
||||||
|
* @param showImdbId - The IMDb ID of the show
|
||||||
|
* @param showId - The Stremio ID of the show (for local storage)
|
||||||
|
* @param episodes - Array of { season, episode } objects
|
||||||
|
* @param watchedAt - Optional date when watched
|
||||||
|
*/
|
||||||
|
public async markEpisodesAsWatched(
|
||||||
|
showImdbId: string,
|
||||||
|
showId: string,
|
||||||
|
episodes: Array<{ season: number; episode: number }>,
|
||||||
|
watchedAt: Date = new Date()
|
||||||
|
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
||||||
|
try {
|
||||||
|
if (episodes.length === 0) {
|
||||||
|
return { success: true, syncedToTrakt: false, count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[WatchedService] Marking ${episodes.length} episodes as watched for ${showImdbId}`);
|
||||||
|
|
||||||
|
// Check if Trakt is authenticated
|
||||||
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||||
|
let syncedToTrakt = false;
|
||||||
|
|
||||||
|
if (isTraktAuth) {
|
||||||
|
// Sync to Trakt (batch operation)
|
||||||
|
syncedToTrakt = await this.traktService.markEpisodesAsWatched(
|
||||||
|
showImdbId,
|
||||||
|
episodes,
|
||||||
|
watchedAt
|
||||||
|
);
|
||||||
|
logger.log(`[WatchedService] Trakt batch sync result: ${syncedToTrakt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store locally as "completed" for each episode
|
||||||
|
for (const ep of episodes) {
|
||||||
|
const episodeId = `${showId}:${ep.season}:${ep.episode}`;
|
||||||
|
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, syncedToTrakt, count: episodes.length };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[WatchedService] Failed to mark episodes as watched:', error);
|
||||||
|
return { success: false, syncedToTrakt: false, count: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an entire season as watched
|
||||||
|
* @param showImdbId - The IMDb ID of the show
|
||||||
|
* @param showId - The Stremio ID of the show (for local storage)
|
||||||
|
* @param season - Season number
|
||||||
|
* @param episodeNumbers - Array of episode numbers in the season
|
||||||
|
* @param watchedAt - Optional date when watched
|
||||||
|
*/
|
||||||
|
public async markSeasonAsWatched(
|
||||||
|
showImdbId: string,
|
||||||
|
showId: string,
|
||||||
|
season: number,
|
||||||
|
episodeNumbers: number[],
|
||||||
|
watchedAt: Date = new Date()
|
||||||
|
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
||||||
|
try {
|
||||||
|
logger.log(`[WatchedService] Marking season ${season} as watched for ${showImdbId}`);
|
||||||
|
|
||||||
|
// Check if Trakt is authenticated
|
||||||
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||||
|
let syncedToTrakt = false;
|
||||||
|
|
||||||
|
if (isTraktAuth) {
|
||||||
|
// Sync entire season to Trakt
|
||||||
|
syncedToTrakt = await this.traktService.markSeasonAsWatched(
|
||||||
|
showImdbId,
|
||||||
|
season,
|
||||||
|
watchedAt
|
||||||
|
);
|
||||||
|
logger.log(`[WatchedService] Trakt season sync result: ${syncedToTrakt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store locally as "completed" for each episode in the season
|
||||||
|
for (const epNum of episodeNumbers) {
|
||||||
|
const episodeId = `${showId}:${season}:${epNum}`;
|
||||||
|
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, syncedToTrakt, count: episodeNumbers.length };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[WatchedService] Failed to mark season as watched:', error);
|
||||||
|
return { success: false, syncedToTrakt: false, count: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmark a movie as watched (remove from history)
|
||||||
|
*/
|
||||||
|
public async unmarkMovieAsWatched(
|
||||||
|
imdbId: string
|
||||||
|
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||||
|
try {
|
||||||
|
logger.log(`[WatchedService] Unmarking movie as watched: ${imdbId}`);
|
||||||
|
|
||||||
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||||
|
let syncedToTrakt = false;
|
||||||
|
|
||||||
|
if (isTraktAuth) {
|
||||||
|
syncedToTrakt = await this.traktService.removeMovieFromHistory(imdbId);
|
||||||
|
logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove local progress
|
||||||
|
await storageService.removeWatchProgress(imdbId, 'movie');
|
||||||
|
await mmkvStorage.removeItem(`watched:movie:${imdbId}`);
|
||||||
|
|
||||||
|
return { success: true, syncedToTrakt };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[WatchedService] Failed to unmark movie as watched:', error);
|
||||||
|
return { success: false, syncedToTrakt: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmark an episode as watched (remove from history)
|
||||||
|
*/
|
||||||
|
public async unmarkEpisodeAsWatched(
|
||||||
|
showImdbId: string,
|
||||||
|
showId: string,
|
||||||
|
season: number,
|
||||||
|
episode: number
|
||||||
|
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
||||||
|
try {
|
||||||
|
logger.log(`[WatchedService] Unmarking episode as watched: ${showImdbId} S${season}E${episode}`);
|
||||||
|
|
||||||
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||||
|
let syncedToTrakt = false;
|
||||||
|
|
||||||
|
if (isTraktAuth) {
|
||||||
|
syncedToTrakt = await this.traktService.removeEpisodeFromHistory(
|
||||||
|
showImdbId,
|
||||||
|
season,
|
||||||
|
episode
|
||||||
|
);
|
||||||
|
logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove local progress
|
||||||
|
const episodeId = `${showId}:${season}:${episode}`;
|
||||||
|
await storageService.removeWatchProgress(showId, 'series', episodeId);
|
||||||
|
|
||||||
|
return { success: true, syncedToTrakt };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[WatchedService] Failed to unmark episode as watched:', error);
|
||||||
|
return { success: false, syncedToTrakt: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmark an entire season as watched (remove from history)
|
||||||
|
* @param showImdbId - The IMDb ID of the show
|
||||||
|
* @param showId - The Stremio ID of the show (for local storage)
|
||||||
|
* @param season - Season number
|
||||||
|
* @param episodeNumbers - Array of episode numbers in the season
|
||||||
|
*/
|
||||||
|
public async unmarkSeasonAsWatched(
|
||||||
|
showImdbId: string,
|
||||||
|
showId: string,
|
||||||
|
season: number,
|
||||||
|
episodeNumbers: number[]
|
||||||
|
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
||||||
|
try {
|
||||||
|
logger.log(`[WatchedService] Unmarking season ${season} as watched for ${showImdbId}`);
|
||||||
|
|
||||||
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
||||||
|
let syncedToTrakt = false;
|
||||||
|
|
||||||
|
if (isTraktAuth) {
|
||||||
|
// Remove entire season from Trakt
|
||||||
|
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
|
||||||
|
showImdbId,
|
||||||
|
season
|
||||||
|
);
|
||||||
|
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove local progress for each episode in the season
|
||||||
|
for (const epNum of episodeNumbers) {
|
||||||
|
const episodeId = `${showId}:${season}:${epNum}`;
|
||||||
|
await storageService.removeWatchProgress(showId, 'series', episodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, syncedToTrakt, count: episodeNumbers.length };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[WatchedService] Failed to unmark season as watched:', error);
|
||||||
|
return { success: false, syncedToTrakt: false, count: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a movie is marked as watched (locally)
|
||||||
|
*/
|
||||||
|
public async isMovieWatched(imdbId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// First check local watched flag
|
||||||
|
const localWatched = await mmkvStorage.getItem(`watched:movie:${imdbId}`);
|
||||||
|
if (localWatched === 'true') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check local progress
|
||||||
|
const progress = await storageService.getWatchProgress(imdbId, 'movie');
|
||||||
|
if (progress) {
|
||||||
|
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||||
|
if (progressPercent >= 85) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[WatchedService] Error checking movie watched status:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an episode is marked as watched (locally)
|
||||||
|
*/
|
||||||
|
public async isEpisodeWatched(showId: string, season: number, episode: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const episodeId = `${showId}:${season}:${episode}`;
|
||||||
|
|
||||||
|
// Check local progress
|
||||||
|
const progress = await storageService.getWatchProgress(showId, 'series', episodeId);
|
||||||
|
if (progress) {
|
||||||
|
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||||
|
if (progressPercent >= 85) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[WatchedService] Error checking episode watched status:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set local watched status by creating a "completed" progress entry
|
||||||
|
*/
|
||||||
|
private async setLocalWatchedStatus(
|
||||||
|
id: string,
|
||||||
|
type: 'movie' | 'series',
|
||||||
|
watched: boolean,
|
||||||
|
episodeId?: string,
|
||||||
|
watchedAt: Date = new Date()
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (watched) {
|
||||||
|
// Create a "completed" progress entry (100% watched)
|
||||||
|
const progress = {
|
||||||
|
currentTime: 1, // Minimal values to indicate completion
|
||||||
|
duration: 1,
|
||||||
|
lastUpdated: watchedAt.getTime(),
|
||||||
|
traktSynced: false, // Will be set to true if Trakt sync succeeded
|
||||||
|
traktProgress: 100,
|
||||||
|
};
|
||||||
|
await storageService.setWatchProgress(id, type, progress, episodeId, {
|
||||||
|
forceWrite: true,
|
||||||
|
forceNotify: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also set the legacy watched flag for movies
|
||||||
|
if (type === 'movie') {
|
||||||
|
await mmkvStorage.setItem(`watched:${type}:${id}`, 'true');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove progress
|
||||||
|
await storageService.removeWatchProgress(id, type, episodeId);
|
||||||
|
if (type === 'movie') {
|
||||||
|
await mmkvStorage.removeItem(`watched:${type}:${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[WatchedService] Error setting local watched status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const watchedService = WatchedService.getInstance();
|
||||||
205
trakt-docs/scrape-trakt-docs.js
Normal file
205
trakt-docs/scrape-trakt-docs.js
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const API_BLUEPRINT_URL = 'https://jsapi.apiary.io/apis/trakt.apib';
|
||||||
|
|
||||||
|
// Category mapping based on group names
|
||||||
|
const CATEGORIES = {
|
||||||
|
'introduction': { file: '01-introduction.md', title: 'Introduction' },
|
||||||
|
'authentication-oauth': { file: '02-authentication-oauth.md', title: 'Authentication - OAuth' },
|
||||||
|
'authentication-devices': { file: '03-authentication-devices.md', title: 'Authentication - Devices' },
|
||||||
|
'calendars': { file: '04-calendars.md', title: 'Calendars' },
|
||||||
|
'checkin': { file: '05-checkin.md', title: 'Checkin' },
|
||||||
|
'certifications': { file: '06-certifications.md', title: 'Certifications' },
|
||||||
|
'comments': { file: '07-comments.md', title: 'Comments' },
|
||||||
|
'countries': { file: '08-countries.md', title: 'Countries' },
|
||||||
|
'genres': { file: '09-genres.md', title: 'Genres' },
|
||||||
|
'languages': { file: '10-languages.md', title: 'Languages' },
|
||||||
|
'lists': { file: '11-lists.md', title: 'Lists' },
|
||||||
|
'movies': { file: '12-movies.md', title: 'Movies' },
|
||||||
|
'networks': { file: '13-networks.md', title: 'Networks' },
|
||||||
|
'notes': { file: '14-notes.md', title: 'Notes' },
|
||||||
|
'people': { file: '15-people.md', title: 'People' },
|
||||||
|
'recommendations': { file: '16-recommendations.md', title: 'Recommendations' },
|
||||||
|
'scrobble': { file: '17-scrobble.md', title: 'Scrobble' },
|
||||||
|
'search': { file: '18-search.md', title: 'Search' },
|
||||||
|
'shows': { file: '19-shows.md', title: 'Shows' },
|
||||||
|
'seasons': { file: '20-seasons.md', title: 'Seasons' },
|
||||||
|
'episodes': { file: '21-episodes.md', title: 'Episodes' },
|
||||||
|
'sync': { file: '22-sync.md', title: 'Sync' },
|
||||||
|
'users': { file: '23-users.md', title: 'Users' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function fetchUrl(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(url, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => resolve(data));
|
||||||
|
res.on('error', reject);
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseApiBlueprint(content) {
|
||||||
|
const sections = {};
|
||||||
|
let currentGroup = 'introduction';
|
||||||
|
let currentContent = [];
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Detect group headers like "# Group Authentication - OAuth"
|
||||||
|
const groupMatch = line.match(/^#\s+Group\s+(.+)$/i);
|
||||||
|
if (groupMatch) {
|
||||||
|
// Save previous group
|
||||||
|
if (currentContent.length > 0) {
|
||||||
|
if (!sections[currentGroup]) sections[currentGroup] = [];
|
||||||
|
sections[currentGroup].push(...currentContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new group
|
||||||
|
const groupName = groupMatch[1].toLowerCase().replace(/\s+/g, '-');
|
||||||
|
currentGroup = groupName;
|
||||||
|
currentContent = [`# ${groupMatch[1]}\n`];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentContent.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save last group
|
||||||
|
if (currentContent.length > 0) {
|
||||||
|
if (!sections[currentGroup]) sections[currentGroup] = [];
|
||||||
|
sections[currentGroup].push(...currentContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertApiBlueprintToMarkdown(content) {
|
||||||
|
let md = content;
|
||||||
|
|
||||||
|
// Convert API Blueprint specific syntax to markdown
|
||||||
|
// Parameters section
|
||||||
|
md = md.replace(/\+ Parameters/g, '### Parameters');
|
||||||
|
|
||||||
|
// Request/Response sections
|
||||||
|
md = md.replace(/\+ Request \(([^)]+)\)/g, '### Request ($1)');
|
||||||
|
md = md.replace(/\+ Response (\d+)(?: \(([^)]+)\))?/g, (match, code, type) => {
|
||||||
|
return type ? `### Response ${code} (${type})` : `### Response ${code}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Body sections
|
||||||
|
md = md.replace(/\+ Body/g, '**Body:**');
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
md = md.replace(/\+ Headers/g, '**Headers:**');
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
md = md.replace(/\+ Attributes/g, '### Attributes');
|
||||||
|
|
||||||
|
// Clean up indentation for code blocks
|
||||||
|
md = md.replace(/^ /gm, ' ');
|
||||||
|
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('🔄 Fetching Trakt API Blueprint...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fetchUrl(API_BLUEPRINT_URL);
|
||||||
|
console.log(`✅ Fetched ${content.length} bytes`);
|
||||||
|
|
||||||
|
// Save raw blueprint
|
||||||
|
fs.writeFileSync(path.join(__dirname, 'raw-api-blueprint.apib'), content);
|
||||||
|
console.log('📝 Saved raw API Blueprint');
|
||||||
|
|
||||||
|
// Parse and organize by groups
|
||||||
|
const sections = parseApiBlueprint(content);
|
||||||
|
console.log(`📂 Found ${Object.keys(sections).length} sections`);
|
||||||
|
|
||||||
|
// Create markdown files for each category
|
||||||
|
for (const [groupKey, lines] of Object.entries(sections)) {
|
||||||
|
const category = CATEGORIES[groupKey];
|
||||||
|
const fileName = category ? category.file : `${groupKey}.md`;
|
||||||
|
const title = category ? category.title : groupKey;
|
||||||
|
|
||||||
|
let mdContent = lines.join('\n');
|
||||||
|
mdContent = convertApiBlueprintToMarkdown(mdContent);
|
||||||
|
|
||||||
|
// Add header if not present
|
||||||
|
if (!mdContent.startsWith('# ')) {
|
||||||
|
mdContent = `# ${title}\n\n${mdContent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(__dirname, fileName);
|
||||||
|
fs.writeFileSync(filePath, mdContent);
|
||||||
|
console.log(`✅ Created ${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create README
|
||||||
|
const readme = generateReadme(Object.keys(sections));
|
||||||
|
fs.writeFileSync(path.join(__dirname, 'README.md'), readme);
|
||||||
|
console.log('✅ Created README.md');
|
||||||
|
|
||||||
|
console.log('\n🎉 Done! All documentation files created.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateReadme(groups) {
|
||||||
|
let md = `# Trakt API Documentation
|
||||||
|
|
||||||
|
This folder contains the complete Trakt API documentation, scraped from [trakt.docs.apiary.io](https://trakt.docs.apiary.io/).
|
||||||
|
|
||||||
|
## API Base URL
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
https://api.trakt.tv
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Documentation Files
|
||||||
|
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const groupKey of groups) {
|
||||||
|
const category = CATEGORIES[groupKey];
|
||||||
|
if (category) {
|
||||||
|
md += `- [${category.title}](./${category.file})\n`;
|
||||||
|
} else {
|
||||||
|
md += `- [${groupKey}](./${groupKey}.md)\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
md += `
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Required Headers
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| \`Content-Type\` | \`application/json\` |
|
||||||
|
| \`trakt-api-key\` | Your \`client_id\` |
|
||||||
|
| \`trakt-api-version\` | \`2\` |
|
||||||
|
| \`Authorization\` | \`Bearer [access_token]\` (for authenticated endpoints) |
|
||||||
|
|
||||||
|
### Useful Links
|
||||||
|
|
||||||
|
- [Create API App](https://trakt.tv/oauth/applications/new)
|
||||||
|
- [GitHub Developer Forum](https://github.com/trakt/api-help/issues)
|
||||||
|
- [API Blog](https://apiblog.trakt.tv)
|
||||||
|
|
||||||
|
---
|
||||||
|
*Generated on ${new Date().toISOString()}*
|
||||||
|
`;
|
||||||
|
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Loading…
Reference in a new issue