diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index d9daf84..029e841 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -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 = ({ onSeasonChange, onSelectEpisode, groupedEpisodes = {}, - metadata + metadata, + imdbId }) => { const { currentTheme } = useTheme(); const { settings } = useSettings(); @@ -180,6 +184,11 @@ const SeriesContentComponent: React.FC = ({ const [posterViewVisible, setPosterViewVisible] = useState(true); const [textViewVisible, setTextViewVisible] = useState(false); + // Episode action menu state + const [episodeActionMenuVisible, setEpisodeActionMenuVisible] = useState(false); + const [selectedEpisodeForAction, setSelectedEpisodeForAction] = useState(null); + const [markingAsWatched, setMarkingAsWatched] = useState(false); + // Add refs for the scroll views const seasonScrollViewRef = useRef(null); const episodeScrollViewRef = useRef>(null); @@ -517,6 +526,207 @@ const SeriesContentComponent: React.FC = ({ 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 ( @@ -826,6 +1036,8 @@ const SeriesContentComponent: React.FC = ({ } ]} onPress={() => onSelectEpisode(episode)} + onLongPress={() => handleEpisodeLongPress(episode)} + delayLongPress={400} activeOpacity={0.7} > = ({ } ]} onPress={() => onSelectEpisode(episode)} + onLongPress={() => handleEpisodeLongPress(episode)} + delayLongPress={400} activeOpacity={0.85} > {/* Solid outline replaces gradient border */} @@ -1438,6 +1652,205 @@ const SeriesContentComponent: React.FC = ({ ) )} + + {/* Episode Action Menu Modal */} + + + e.stopPropagation()} + > + {/* Header */} + + + {selectedEpisodeForAction ? `S${selectedEpisodeForAction.season_number}E${selectedEpisodeForAction.episode_number}` : ''} + + + {selectedEpisodeForAction?.name || ''} + + + + {/* Action buttons */} + + {/* Mark as Watched / Unwatched */} + {selectedEpisodeForAction && ( + isEpisodeWatched(selectedEpisodeForAction) ? ( + + + + {markingAsWatched ? 'Removing...' : 'Mark as Unwatched'} + + + ) : ( + + + + {markingAsWatched ? 'Marking...' : 'Mark as Watched'} + + + ) + )} + + {/* Mark Season as Watched / Unwatched */} + {isSeasonWatched() ? ( + + + + {markingAsWatched ? 'Removing...' : `Unmark Season ${selectedSeason}`} + + + ) : ( + + + + {markingAsWatched ? 'Marking...' : `Mark Season ${selectedSeason}`} + + + )} + + {/* Cancel */} + + + Cancel + + + + + + ); }; diff --git a/src/hooks/useGithubMajorUpdate.ts b/src/hooks/useGithubMajorUpdate.ts index af3b51f..1312579 100644 --- a/src/hooks/useGithubMajorUpdate.ts +++ b/src/hooks/useGithubMajorUpdate.ts @@ -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(); const check = useCallback(async () => { + if (Platform.OS === 'ios') return; try { // Always compare with Settings screen version const current = getDisplayedAppVersion() || Updates.runtimeVersion || '0.0.0'; diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index 57a658d..a81b4eb 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -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[]); diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index ccb5f8c..28c90c3 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -1274,6 +1274,7 @@ const MetadataScreen: React.FC = () => { onSelectEpisode={handleEpisodeSelect} groupedEpisodes={groupedEpisodes} metadata={metadata || undefined} + imdbId={imdbId || undefined} /> ) : ( metadata && diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 3df54ee..408a629 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -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 { + 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 { + 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>(); + 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 { + 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 { + 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 */ diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts new file mode 100644 index 0000000..3436496 --- /dev/null +++ b/src/services/watchedService.ts @@ -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 { + 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 { + 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 { + 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(); diff --git a/trakt-docs/scrape-trakt-docs.js b/trakt-docs/scrape-trakt-docs.js new file mode 100644 index 0000000..7b5d44c --- /dev/null +++ b/trakt-docs/scrape-trakt-docs.js @@ -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();