mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +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 { 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 { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
|
@ -12,6 +13,7 @@ import { storageService } from '../../services/storageService';
|
|||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import Animated, { FadeIn, FadeOut, SlideInRight, SlideOutLeft } from 'react-native-reanimated';
|
||||
import { TraktService } from '../../services/traktService';
|
||||
import { watchedService } from '../../services/watchedService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { mmkvStorage } from '../../services/mmkvStorage';
|
||||
|
||||
|
|
@ -31,6 +33,7 @@ interface SeriesContentProps {
|
|||
onSelectEpisode: (episode: Episode) => void;
|
||||
groupedEpisodes?: { [seasonNumber: number]: Episode[] };
|
||||
metadata?: { poster?: string; id?: string };
|
||||
imdbId?: string; // IMDb ID for Trakt sync
|
||||
}
|
||||
|
||||
// Add placeholder constant at the top
|
||||
|
|
@ -46,7 +49,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
onSeasonChange,
|
||||
onSelectEpisode,
|
||||
groupedEpisodes = {},
|
||||
metadata
|
||||
metadata,
|
||||
imdbId
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
|
|
@ -180,6 +184,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
const [posterViewVisible, setPosterViewVisible] = useState(true);
|
||||
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
|
||||
const seasonScrollViewRef = useRef<ScrollView | null>(null);
|
||||
const episodeScrollViewRef = useRef<FlashListRef<Episode>>(null);
|
||||
|
|
@ -517,6 +526,207 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
return rating ?? null;
|
||||
}, [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) {
|
||||
return (
|
||||
<View style={styles.centeredContainer}>
|
||||
|
|
@ -826,6 +1036,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
}
|
||||
]}
|
||||
onPress={() => onSelectEpisode(episode)}
|
||||
onLongPress={() => handleEpisodeLongPress(episode)}
|
||||
delayLongPress={400}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[
|
||||
|
|
@ -1107,6 +1319,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
}
|
||||
]}
|
||||
onPress={() => onSelectEpisode(episode)}
|
||||
onLongPress={() => handleEpisodeLongPress(episode)}
|
||||
delayLongPress={400}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{/* Solid outline replaces gradient border */}
|
||||
|
|
@ -1438,6 +1652,205 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
)
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
import * as Updates from 'expo-updates';
|
||||
import { getDisplayedAppVersion } from '../utils/version';
|
||||
|
|
@ -23,6 +24,7 @@ export function useGithubMajorUpdate(): MajorUpdateData {
|
|||
const [releaseUrl, setReleaseUrl] = useState<string | undefined>();
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (Platform.OS === 'ios') return;
|
||||
try {
|
||||
// Always compare with Settings screen version
|
||||
const current = getDisplayedAppVersion() || Updates.runtimeVersion || '0.0.0';
|
||||
|
|
|
|||
|
|
@ -662,12 +662,16 @@ const AddonsScreen = () => {
|
|||
const installedAddons = await stremioService.getInstalledAddonsAsync();
|
||||
|
||||
// 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 isTorboxAddon =
|
||||
addon.id?.includes('torbox') ||
|
||||
addon.url?.includes('torbox') ||
|
||||
(addon as any).transport?.includes('torbox');
|
||||
return !isTorboxAddon;
|
||||
const isOfficialTorboxAddon =
|
||||
addon.url?.includes('stremio.torbox.app') ||
|
||||
(addon as any).transport?.includes('stremio.torbox.app') ||
|
||||
// Check for ID but be careful not to catch others if possible, though ID usually comes from URL in stremioService
|
||||
(addon.id?.includes('stremio.torbox.app'));
|
||||
|
||||
return !isOfficialTorboxAddon;
|
||||
});
|
||||
|
||||
setAddons(filteredAddons as ExtendedManifest[]);
|
||||
|
|
|
|||
|
|
@ -1274,6 +1274,7 @@ const MetadataScreen: React.FC = () => {
|
|||
onSelectEpisode={handleEpisodeSelect}
|
||||
groupedEpisodes={groupedEpisodes}
|
||||
metadata={metadata || undefined}
|
||||
imdbId={imdbId || undefined}
|
||||
/>
|
||||
) : (
|
||||
metadata && <MemoizedMovieContent metadata={metadata} />
|
||||
|
|
|
|||
|
|
@ -1326,11 +1326,15 @@ export class TraktService {
|
|||
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 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', {
|
||||
episodes: [
|
||||
shows: [
|
||||
{
|
||||
ids: {
|
||||
trakt: traktId
|
||||
|
|
@ -1349,6 +1353,7 @@ export class TraktService {
|
|||
}
|
||||
]
|
||||
});
|
||||
logger.log(`[TraktService] Successfully marked S${season}E${episode} as watched`);
|
||||
return true;
|
||||
} catch (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
|
||||
*/
|
||||
|
|
|
|||
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