added episode/season mark as watched feature syncing locally/trakt.

This commit is contained in:
tapframe 2025-12-16 15:17:56 +05:30
parent 60cdf9fe86
commit d876b7618c
7 changed files with 1218 additions and 8 deletions

View file

@ -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>
);
};

View file

@ -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';

View file

@ -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[]);

View file

@ -1274,6 +1274,7 @@ const MetadataScreen: React.FC = () => {
onSelectEpisode={handleEpisodeSelect}
groupedEpisodes={groupedEpisodes}
metadata={metadata || undefined}
imdbId={imdbId || undefined}
/>
) : (
metadata && <MemoizedMovieContent metadata={metadata} />

View file

@ -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
*/

View 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();

View 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();