diff --git a/.gitignore b/.gitignore index b953572e..84f27aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -97,4 +97,5 @@ trakt-docss # Removed submodules (kept locally) libmpv-android/ mpv-android/ -mpvKt/ \ No newline at end of file +mpvKt/ +simkl-docss \ No newline at end of file diff --git a/src/components/icons/SimklIcon.tsx b/src/components/icons/SimklIcon.tsx new file mode 100644 index 00000000..8f2b5133 --- /dev/null +++ b/src/components/icons/SimklIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Svg, { Path } from 'react-native-svg'; + +interface SimklIconProps { + size?: number; + color?: string; + style?: any; +} + +const SimklIcon: React.FC = ({ size = 24, color = '#000000', style }) => { + return ( + + + + ); +}; + +export default SimklIcon; diff --git a/src/hooks/useSimklIntegration.ts b/src/hooks/useSimklIntegration.ts new file mode 100644 index 00000000..a7bdd1bf --- /dev/null +++ b/src/hooks/useSimklIntegration.ts @@ -0,0 +1,227 @@ +import { useState, useEffect, useCallback } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; +import { + SimklService, + SimklContentData, + SimklPlaybackData +} from '../services/simklService'; +import { storageService } from '../services/storageService'; +import { logger } from '../utils/logger'; + +const simklService = SimklService.getInstance(); + +export function useSimklIntegration() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + // Basic lists + const [continueWatching, setContinueWatching] = useState([]); + + // Check authentication status + const checkAuthStatus = useCallback(async () => { + setIsLoading(true); + try { + const authenticated = await simklService.isAuthenticated(); + setIsAuthenticated(authenticated); + } catch (error) { + logger.error('[useSimklIntegration] Error checking auth status:', error); + } finally { + setIsLoading(false); + } + }, []); + + // Force refresh + const refreshAuthStatus = useCallback(async () => { + await checkAuthStatus(); + }, [checkAuthStatus]); + + // Load playback/continue watching + const loadPlaybackStatus = useCallback(async () => { + if (!isAuthenticated) return; + try { + const playback = await simklService.getPlaybackStatus(); + setContinueWatching(playback); + } catch (error) { + logger.error('[useSimklIntegration] Error loading playback status:', error); + } + }, [isAuthenticated]); + + // Start watching (scrobble start) + const startWatching = useCallback(async (content: SimklContentData, progress: number): Promise => { + if (!isAuthenticated) return false; + try { + const res = await simklService.scrobbleStart(content, progress); + return !!res; + } catch (error) { + logger.error('[useSimklIntegration] Error starting watch:', error); + return false; + } + }, [isAuthenticated]); + + // Update progress (scrobble pause) + const updateProgress = useCallback(async (content: SimklContentData, progress: number): Promise => { + if (!isAuthenticated) return false; + try { + const res = await simklService.scrobblePause(content, progress); + return !!res; + } catch (error) { + logger.error('[useSimklIntegration] Error updating progress:', error); + return false; + } + }, [isAuthenticated]); + + // Stop watching (scrobble stop) + const stopWatching = useCallback(async (content: SimklContentData, progress: number): Promise => { + if (!isAuthenticated) return false; + try { + const res = await simklService.scrobbleStop(content, progress); + return !!res; + } catch (error) { + logger.error('[useSimklIntegration] Error stopping watch:', error); + return false; + } + }, [isAuthenticated]); + + // Sync All Local Progress -> Simkl + const syncAllProgress = useCallback(async (): Promise => { + if (!isAuthenticated) return false; + + try { + const unsynced = await storageService.getUnsyncedProgress(); + // Filter for items that specifically need SIMKL sync (unsynced.filter(i => !i.progress.simklSynced...)) + // storageService.getUnsyncedProgress currently returns items that need Trakt OR Simkl sync. + // We should check simklSynced specifically here. + + const itemsToSync = unsynced.filter(i => !i.progress.simklSynced || (i.progress.simklLastSynced && i.progress.lastUpdated > i.progress.simklLastSynced)); + + if (itemsToSync.length === 0) return true; + + logger.log(`[useSimklIntegration] Found ${itemsToSync.length} items to sync to Simkl`); + + for (const item of itemsToSync) { + try { + const season = item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined; + const episode = item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined; + + // Construct content data + const content: SimklContentData = { + type: item.type === 'series' ? 'episode' : 'movie', + title: 'Unknown', // Ideally storage has title, but it might not. Simkl needs IDs mainly. + ids: { imdb: item.id }, + season, + episode + }; + + const progressPercent = (item.progress.currentTime / item.progress.duration) * 100; + + // If completed (>=80% or 95% depending on logic, let's say 85% safe), add to history + // Simkl: Stop with >= 80% marks as watched. + // Or explicitly add to history. + + let success = false; + if (progressPercent >= 85) { + // Add to history + if (content.type === 'movie') { + await simklService.addToHistory({ movies: [{ ids: { imdb: item.id } }] }); + } else { + await simklService.addToHistory({ shows: [{ ids: { imdb: item.id }, seasons: [{ number: season, episodes: [{ number: episode }] }] }] }); + } + success = true; // Assume success if no throw + } else { + // Pause (scrobble) + const res = await simklService.scrobblePause(content, progressPercent); + success = !!res; + } + + if (success) { + await storageService.updateSimklSyncStatus(item.id, item.type, true, progressPercent, item.episodeId); + } + } catch (e) { + logger.error(`[useSimklIntegration] Failed to sync item ${item.id}`, e); + } + } + return true; + } catch (e) { + logger.error('[useSimklIntegration] Error syncing all progress', e); + return false; + } + }, [isAuthenticated]); + + // Fetch Simkl -> Merge Local + const fetchAndMergeSimklProgress = useCallback(async (): Promise => { + if (!isAuthenticated) return false; + + try { + const playback = await simklService.getPlaybackStatus(); + + for (const item of playback) { + let id: string | undefined; + let type: string; + let episodeId: string | undefined; + + if (item.movie) { + id = item.movie.ids.imdb; + type = 'movie'; + } else if (item.show && item.episode) { + id = item.show.ids.imdb; + type = 'series'; + episodeId = `${id}:${item.episode.season}:${item.episode.episode}`; + } + + if (id) { + await storageService.mergeWithSimklProgress( + id, + type!, + item.progress, + item.paused_at, + episodeId + ); + + // Mark as synced locally so we don't push it back + await storageService.updateSimklSyncStatus(id, type!, true, item.progress, episodeId); + } + } + return true; + } catch (e) { + logger.error('[useSimklIntegration] Error fetching/merging Simkl progress', e); + return false; + } + }, [isAuthenticated]); + + // Effects + useEffect(() => { + checkAuthStatus(); + }, [checkAuthStatus]); + + useEffect(() => { + if (isAuthenticated) { + loadPlaybackStatus(); + fetchAndMergeSimklProgress(); + } + }, [isAuthenticated, loadPlaybackStatus, fetchAndMergeSimklProgress]); + + // App state listener for sync + useEffect(() => { + if (!isAuthenticated) return; + const sub = AppState.addEventListener('change', (state) => { + if (state === 'active') { + fetchAndMergeSimklProgress(); + } + }); + return () => sub.remove(); + }, [isAuthenticated, fetchAndMergeSimklProgress]); + + + return { + isAuthenticated, + isLoading, + checkAuthStatus, + refreshAuthStatus, + startWatching, + updateProgress, + stopWatching, + syncAllProgress, + fetchAndMergeSimklProgress, + continueWatching + }; +} diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index 4200e1cb..57bec23a 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -1,7 +1,9 @@ import { useCallback, useRef, useEffect } from 'react'; import { useTraktIntegration } from './useTraktIntegration'; +import { useSimklIntegration } from './useSimklIntegration'; import { useTraktAutosyncSettings } from './useTraktAutosyncSettings'; import { TraktContentData } from '../services/traktService'; +import { SimklContentData } from '../services/simklService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; @@ -30,6 +32,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { stopWatchingImmediate } = useTraktIntegration(); + const { + isAuthenticated: isSimklAuthenticated, + startWatching: startSimkl, + updateProgress: updateSimkl, + stopWatching: stopSimkl + } = useSimklIntegration(); + const { settings: autosyncSettings } = useTraktAutosyncSettings(); const hasStartedWatching = useRef(false); @@ -145,14 +154,29 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } }, [options]); + const buildSimklContentData = useCallback((): SimklContentData => { + return { + type: options.type === 'series' ? 'episode' : 'movie', + title: options.title, + ids: { + imdb: options.imdbId + }, + season: options.season, + episode: options.episode + }; + }, [options]); + // Start watching (scrobble start) const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => { if (isUnmounted.current) return; // Prevent execution after component unmount logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`); - if (!isAuthenticated || !autosyncSettings.enabled) { - logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); + const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled; + const shouldSyncSimkl = isSimklAuthenticated; + + if (!shouldSyncTrakt && !shouldSyncSimkl) { + logger.log(`[TraktAutosync] Skipping handlePlaybackStart: Trakt (auth=${isAuthenticated}, enabled=${autosyncSettings.enabled}), Simkl (auth=${isSimklAuthenticated})`); return; } @@ -190,16 +214,28 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } - const success = await startWatching(contentData, progressPercent); - if (success) { + if (shouldSyncTrakt) { + const success = await startWatching(contentData, progressPercent); + if (success) { + hasStartedWatching.current = true; + hasStopped.current = false; // Reset stop flag when starting + logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`); + } + } else { + // If Trakt is disabled but Simkl is enabled, we still mark stated/stopped flags for local logic hasStartedWatching.current = true; - hasStopped.current = false; // Reset stop flag when starting - logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`); + hasStopped.current = false; + } + + // Simkl Start + if (shouldSyncSimkl) { + const simklData = buildSimklContentData(); + await startSimkl(simklData, progressPercent); } } catch (error) { logger.error('[TraktAutosync] Error starting watch:', error); } - }, [isAuthenticated, autosyncSettings.enabled, startWatching, buildContentData]); + }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, startWatching, startSimkl, buildContentData, buildSimklContentData]); // Sync progress during playback const handleProgressUpdate = useCallback(async ( @@ -209,7 +245,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { ) => { if (isUnmounted.current) return; // Prevent execution after component unmount - if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) { + const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled; + const shouldSyncSimkl = isSimklAuthenticated; + + if ((!shouldSyncTrakt && !shouldSyncSimkl) || duration <= 0) { return; } @@ -225,70 +264,95 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // IMMEDIATE SYNC: Use immediate method for user-triggered actions (force=true) // Use regular queued method for background periodic syncs - let success: boolean; + let traktSuccess: boolean = false; - if (force) { - // IMMEDIATE: User action (pause/unpause) - bypass queue - const contentData = buildContentData(); - if (!contentData) { - logger.warn('[TraktAutosync] Skipping progress update: invalid content data'); - return; - } - success = await updateProgressImmediate(contentData, progressPercent); + if (shouldSyncTrakt) { + if (force) { + // IMMEDIATE: User action (pause/unpause) - bypass queue + const contentData = buildContentData(); + if (!contentData) { + logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data'); + return; + } + traktSuccess = await updateProgressImmediate(contentData, progressPercent); - if (success) { - lastSyncTime.current = now; - lastSyncProgress.current = progressPercent; + if (traktSuccess) { + lastSyncTime.current = now; + lastSyncProgress.current = progressPercent; - // Update local storage sync status - await storageService.updateTraktSyncStatus( - options.id, - options.type, - true, - progressPercent, - options.episodeId, - currentTime - ); + // Update local storage sync status + await storageService.updateTraktSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId, + currentTime + ); - logger.log(`[TraktAutosync] IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`); - } - } else { - // BACKGROUND: Periodic sync - use queued method - const progressDiff = Math.abs(progressPercent - lastSyncProgress.current); + logger.log(`[TraktAutosync] Trakt IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`); + } + } else { + // BACKGROUND: Periodic sync - use queued method + const progressDiff = Math.abs(progressPercent - lastSyncProgress.current); - // Only skip if not forced and progress difference is minimal (< 0.5%) - if (progressDiff < 0.5) { - return; - } + // Only skip if not forced and progress difference is minimal (< 0.5%) + if (progressDiff < 0.5) { + logger.log(`[TraktAutosync] Trakt: Skipping periodic progress update, progress diff too small (${progressDiff.toFixed(2)}%)`); + // If only Trakt is active and we skip, we should return here. + // If Simkl is also active, we continue to let Simkl update. + if (!shouldSyncSimkl) return; + } - const contentData = buildContentData(); - if (!contentData) { - logger.warn('[TraktAutosync] Skipping progress update: invalid content data'); - return; - } - success = await updateProgress(contentData, progressPercent, force); + const contentData = buildContentData(); + if (!contentData) { + logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data'); + return; + } + traktSuccess = await updateProgress(contentData, progressPercent, force); - if (success) { - lastSyncTime.current = now; - lastSyncProgress.current = progressPercent; + if (traktSuccess) { + lastSyncTime.current = now; + lastSyncProgress.current = progressPercent; - // Update local storage sync status - await storageService.updateTraktSyncStatus( - options.id, - options.type, - true, - progressPercent, - options.episodeId, - currentTime - ); + // Update local storage sync status + await storageService.updateTraktSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId, + currentTime + ); - // Progress sync logging removed + // Progress sync logging removed + logger.log(`[TraktAutosync] Trakt: Progress updated to ${progressPercent.toFixed(1)}%`); + } } } + + // Simkl Update (No immediate/queued differentiation for now in Simkl hook, just call update) + if (shouldSyncSimkl) { + // Debounce simkl updates slightly if needed, but hook handles calls. + // We do basic difference check here + const simklData = buildSimklContentData(); + await updateSimkl(simklData, progressPercent); + + // Update local storage for Simkl + await storageService.updateSimklSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId + ); + logger.log(`[TraktAutosync] Simkl: Progress updated to ${progressPercent.toFixed(1)}%`); + } + } catch (error) { logger.error('[TraktAutosync] Error syncing progress:', error); } - }, [isAuthenticated, autosyncSettings.enabled, updateProgress, updateProgressImmediate, buildContentData, options]); + }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, updateProgress, updateSimkl, updateProgressImmediate, buildContentData, buildSimklContentData, options]); // Handle playback end/pause const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => { @@ -298,8 +362,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Removed excessive logging for handlePlaybackEnd calls - if (!isAuthenticated || !autosyncSettings.enabled) { - // logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); + const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled; + const shouldSyncSimkl = isSimklAuthenticated; + + if (!shouldSyncTrakt && !shouldSyncSimkl) { + logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: Neither Trakt nor Simkl are active.`); return; } @@ -323,6 +390,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { isSignificantUpdate = true; } else { // Already stopped this session, skipping duplicate call + logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: session already stopped and no significant progress improvement.`); return; } } @@ -390,8 +458,20 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { if (!hasStartedWatching.current && progressPercent > 1) { const contentData = buildContentData(); if (contentData) { - const success = await startWatching(contentData, progressPercent); - if (success) { + let started = false; + // Try starting Trakt if enabled + if (shouldSyncTrakt) { + const s = await startWatching(contentData, progressPercent); + if (s) started = true; + } + // Try starting Simkl if enabled (always 'true' effectively if authenticated) + if (shouldSyncSimkl) { + const simklData = buildSimklContentData(); + await startSimkl(simklData, progressPercent); + started = true; + } + + if (started) { hasStartedWatching.current = true; } } @@ -401,6 +481,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Lower threshold for unmount calls to catch more edge cases if (reason === 'unmount' && progressPercent < 0.5) { // Early unmount stop logging removed + logger.log(`[TraktAutosync] Skipping unmount stop call due to minimal progress (${progressPercent.toFixed(1)}%)`); return; } @@ -419,13 +500,24 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } - // IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends - const success = useImmediate - ? await stopWatchingImmediate(contentData, progressPercent) - : await stopWatching(contentData, progressPercent); + let overallSuccess = false; - if (success) { - // Update local storage sync status + // IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends + let traktStopSuccess = false; + if (shouldSyncTrakt) { + traktStopSuccess = useImmediate + ? await stopWatchingImmediate(contentData, progressPercent) + : await stopWatching(contentData, progressPercent); + if (traktStopSuccess) { + logger.log(`[TraktAutosync] Trakt: ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); + overallSuccess = true; + } else { + logger.warn(`[TraktAutosync] Trakt: Failed to stop watching.`); + } + } + + if (traktStopSuccess) { + // Update local storage sync status for Trakt await storageService.updateTraktSyncStatus( options.id, options.type, @@ -434,7 +526,30 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { options.episodeId, currentTime ); + } else if (shouldSyncTrakt) { + // If Trakt stop failed, reset the stop flag so we can try again later + hasStopped.current = false; + logger.warn(`[TraktAutosync] Trakt: Failed to stop watching, reset stop flag for retry`); + } + // Simkl Stop + if (shouldSyncSimkl) { + const simklData = buildSimklContentData(); + await stopSimkl(simklData, progressPercent); + + // Update local storage sync status for Simkl + await storageService.updateSimklSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId + ); + logger.log(`[TraktAutosync] Simkl: Successfully stopped watching: ${simklData.title} (${progressPercent.toFixed(1)}% - ${reason})`); + overallSuccess = true; // Mark overall success if at least one worked (Simkl doesn't have immediate/queued logic yet) + } + + if (overallSuccess) { // Mark session as complete if >= user completion threshold if (progressPercent >= autosyncSettings.completionThreshold) { isSessionComplete.current = true; @@ -450,8 +565,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { currentTime: duration, duration, lastUpdated: Date.now(), - traktSynced: true, - traktProgress: Math.max(progressPercent, 100), + traktSynced: shouldSyncTrakt ? true : undefined, + traktProgress: shouldSyncTrakt ? Math.max(progressPercent, 100) : undefined, + simklSynced: shouldSyncSimkl ? true : undefined, + simklProgress: shouldSyncSimkl ? Math.max(progressPercent, 100) : undefined, } as any, options.episodeId, { forceNotify: true } @@ -460,11 +577,14 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } catch { } } - logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); + // General success log if at least one service succeeded + if (!shouldSyncTrakt || traktStopSuccess) { // Only log this if Trakt succeeded or wasn't active + logger.log(`[TraktAutosync] Overall: Successfully processed stop for: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); + } } else { - // If stop failed, reset the stop flag so we can try again later + // If neither service succeeded, reset the stop flag hasStopped.current = false; - logger.warn(`[TraktAutosync] Failed to stop watching, reset stop flag for retry`); + logger.warn(`[TraktAutosync] Overall: Failed to stop watching, reset stop flag for retry`); } // Reset state only for natural end or very high progress unmounts @@ -480,7 +600,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Reset stop flag on error so we can try again hasStopped.current = false; } - }, [isAuthenticated, autosyncSettings.enabled, stopWatching, stopWatchingImmediate, startWatching, buildContentData, options]); + }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, stopWatching, stopSimkl, stopWatchingImmediate, startWatching, buildContentData, buildSimklContentData, options]); // Reset state (useful when switching content) const resetState = useCallback(() => { diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index fd1790cc..f0a91093 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -55,6 +55,7 @@ import TMDBSettingsScreen from '../screens/TMDBSettingsScreen'; import HomeScreenSettings from '../screens/HomeScreenSettings'; import HeroCatalogsScreen from '../screens/HeroCatalogsScreen'; import TraktSettingsScreen from '../screens/TraktSettingsScreen'; +import SimklSettingsScreen from '../screens/SimklSettingsScreen'; import PlayerSettingsScreen from '../screens/PlayerSettingsScreen'; import ThemeScreen from '../screens/ThemeScreen'; import OnboardingScreen from '../screens/OnboardingScreen'; @@ -185,6 +186,7 @@ export type RootStackParamList = { HomeScreenSettings: undefined; HeroCatalogs: undefined; TraktSettings: undefined; + SimklSettings: undefined; PlayerSettings: undefined; ThemeSettings: undefined; ScraperSettings: undefined; @@ -1565,6 +1567,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + = 768; @@ -201,6 +203,7 @@ const SettingsScreen: React.FC = () => { const navigation = useNavigation>(); const { lastUpdate } = useCatalogContext(); const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext(); + const { isAuthenticated: isSimklAuthenticated } = useSimklIntegration(); const { currentTheme } = useTheme(); // Tablet-specific state @@ -378,6 +381,17 @@ const SettingsScreen: React.FC = () => { customIcon={} renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} + isLast={!isItemVisible('simkl')} + isTablet={isTablet} + /> + )} + {isItemVisible('simkl') && ( + } + renderControl={() => } + onPress={() => navigation.navigate('SimklSettings')} isLast={true} isTablet={isTablet} /> @@ -618,7 +632,7 @@ const SettingsScreen: React.FC = () => { contentContainerStyle={[styles.bottomSheetContent, { paddingBottom: insets.bottom + 16 }]} > { - LOCALES.sort((a,b) => a.key.localeCompare(b.key)).map(l => + LOCALES.sort((a, b) => a.key.localeCompare(b.key)).map(l => { contentContainerStyle={styles.scrollContent} > {/* Account */} - {(settingsConfig?.categories?.['account']?.visible !== false) && isItemVisible('trakt') && ( + {(settingsConfig?.categories?.['account']?.visible !== false) && (isItemVisible('trakt') || isItemVisible('simkl')) && ( {isItemVisible('trakt') && ( { customIcon={} renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} - isLast + isLast={!isItemVisible('simkl')} + /> + )} + {isItemVisible('simkl') && ( + } + renderControl={() => } + onPress={() => navigation.navigate('SimklSettings')} + isLast={true} /> )} @@ -940,7 +964,7 @@ const SettingsScreen: React.FC = () => { contentContainerStyle={[styles.bottomSheetContent, { paddingBottom: insets.bottom + 16 }]} > { - LOCALES.sort((a,b) => a.key.localeCompare(b.key)).map(l => + LOCALES.sort((a, b) => a.key.localeCompare(b.key)).map(l => { + const { settings } = useSettings(); + const isDarkMode = settings.enableDarkMode; + const navigation = useNavigation(); + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + + const { currentTheme } = useTheme(); + + const { + isAuthenticated, + isLoading, + checkAuthStatus, + refreshAuthStatus + } = useSimklIntegration(); + const { isAuthenticated: isTraktAuthenticated } = useTraktIntegration(); + + const [isExchangingCode, setIsExchangingCode] = useState(false); + + const openAlert = (title: string, message: string) => { + setAlertTitle(title); + setAlertMessage(message); + setAlertVisible(true); + }; + + // Setup expo-auth-session hook + const [request, response, promptAsync] = useAuthRequest( + { + clientId: SIMKL_CLIENT_ID, + scopes: [], // Simkl doesn't strictly use scopes for basic access + redirectUri: SIMKL_REDIRECT_URI, // Must match what is set in Simkl Dashboard + responseType: ResponseType.Code, + // codeChallengeMethod: CodeChallengeMethod.S256, // Simkl might not verify PKCE, but standard compliant + }, + discovery + ); + + useEffect(() => { + checkAuthStatus(); + }, [checkAuthStatus]); + + // Handle the response from the auth request + useEffect(() => { + if (response) { + if (response.type === 'success') { + const { code } = response.params; + setIsExchangingCode(true); + logger.log('[SimklSettingsScreen] Auth code received, exchanging...'); + + simklService.exchangeCodeForToken(code) + .then(success => { + if (success) { + refreshAuthStatus(); + openAlert('Success', 'Connected to Simkl successfully!'); + } else { + openAlert('Error', 'Failed to connect to Simkl.'); + } + }) + .catch(err => { + logger.error('[SimklSettingsScreen] Token exchange error:', err); + openAlert('Error', 'An error occurred during connection.'); + }) + .finally(() => setIsExchangingCode(false)); + } else if (response.type === 'error') { + openAlert('Error', 'Authentication error: ' + (response.error?.message || 'Unknown')); + } + } + }, [response, refreshAuthStatus]); + + const handleSignIn = () => { + if (!SIMKL_CLIENT_ID) { + openAlert('Configuration Error', 'Simkl Client ID is missing in environment variables.'); + return; + } + + if (isTraktAuthenticated) { + openAlert('Conflict', 'You cannot connect to Simkl while Trakt is connected. Please disconnect Trakt first.'); + return; + } + + promptAsync(); + }; + + const handleSignOut = async () => { + await simklService.logout(); + refreshAuthStatus(); + openAlert('Signed Out', 'You have disconnected from Simkl.'); + }; + + return ( + + + + navigation.goBack()} + style={styles.backButton} + > + + + Settings + + + + + + Simkl Integration + + + + + {isLoading ? ( + + + + ) : isAuthenticated ? ( + + + Connected + + + Your watched items are syncing with Simkl. + + + Disconnect + + + ) : ( + + + Connect Simkl + + + Sync your watch history and track what you're watching. + + + {isExchangingCode ? ( + + ) : ( + Sign In with Simkl + )} + + + )} + + + + Nuvio is not affiliated with Simkl. + + + + setAlertVisible(false)} + actions={[{ label: 'OK', onPress: () => setAlertVisible(false) }]} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + marginLeft: 8, + }, + headerTitle: { + fontSize: 34, + fontWeight: 'bold', + paddingHorizontal: 16, + marginBottom: 24, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 16, + paddingBottom: 32, + }, + card: { + borderRadius: 12, + overflow: 'hidden', + padding: 20, + marginBottom: 16, + elevation: 2, + shadowColor: '#000', + shadowOpacity: 0.1, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + }, + loadingContainer: { + padding: 40, + alignItems: 'center', + }, + signInContainer: { + alignItems: 'center', + paddingVertical: 20, + }, + signInTitle: { + fontSize: 20, + fontWeight: '600', + marginBottom: 8, + }, + signInDescription: { + textAlign: 'center', + marginBottom: 20, + fontSize: 15, + }, + profileContainer: { + alignItems: 'center', + paddingVertical: 20, + }, + statusTitle: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, + statusDesc: { + fontSize: 15, + marginBottom: 10, + }, + button: { + width: '100%', + height: 48, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + color: 'white', + }, + disclaimer: { + fontSize: 12, + textAlign: 'center', + marginTop: 20, + }, +}); + +export default SimklSettingsScreen; diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index 68c3c333..952fa1a2 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -23,6 +23,7 @@ import TraktIcon from '../../assets/rating-icons/trakt.svg'; import { useTheme } from '../contexts/ThemeContext'; import { useTraktIntegration } from '../hooks/useTraktIntegration'; import { useTraktAutosyncSettings } from '../hooks/useTraktAutosyncSettings'; +import { useSimklIntegration } from '../hooks/useSimklIntegration'; import { colors } from '../styles'; import CustomAlert from '../components/CustomAlert'; import { useTranslation } from 'react-i18next'; @@ -67,6 +68,7 @@ const TraktSettingsScreen: React.FC = () => { isLoading: traktLoading, refreshAuthStatus } = useTraktIntegration(); + const { isAuthenticated: isSimklAuthenticated } = useSimklIntegration(); const [showSyncFrequencyModal, setShowSyncFrequencyModal] = useState(false); const [showThresholdModal, setShowThresholdModal] = useState(false); @@ -184,6 +186,10 @@ const TraktSettingsScreen: React.FC = () => { }, [response, checkAuthStatus, request?.codeVerifier, navigation]); const handleSignIn = () => { + if (isSimklAuthenticated) { + openAlert('Conflict', 'You cannot connect to Trakt while Simkl is connected. Please disconnect Simkl first.'); + return; + } promptAsync(); // Trigger the authentication flow }; diff --git a/src/services/simklService.ts b/src/services/simklService.ts new file mode 100644 index 00000000..ae016c5c --- /dev/null +++ b/src/services/simklService.ts @@ -0,0 +1,509 @@ +import { mmkvStorage } from './mmkvStorage'; +import { AppState, AppStateStatus } from 'react-native'; +import { logger } from '../utils/logger'; + +// Storage keys +export const SIMKL_ACCESS_TOKEN_KEY = 'simkl_access_token'; + +// Simkl API configuration +const SIMKL_API_URL = 'https://api.simkl.com'; +const SIMKL_CLIENT_ID = process.env.EXPO_PUBLIC_SIMKL_CLIENT_ID as string; +const SIMKL_CLIENT_SECRET = process.env.EXPO_PUBLIC_SIMKL_CLIENT_SECRET as string; +const SIMKL_REDIRECT_URI = process.env.EXPO_PUBLIC_SIMKL_REDIRECT_URI || 'nuvio://auth/simkl'; + +if (!SIMKL_CLIENT_ID || !SIMKL_CLIENT_SECRET) { + logger.warn('[SimklService] Missing Simkl env vars. Simkl integration will be disabled.'); +} + +// Types +export interface SimklUser { + user: { + name: string; + joined_at: string; + avatar: string; + } +} + +export interface SimklIds { + simkl?: number; + slug?: string; + imdb?: string; + tmdb?: number; + mal?: string; + tvdb?: string; + anidb?: string; +} + +export interface SimklContentData { + type: 'movie' | 'episode' | 'anime'; + title: string; + year?: number; + ids: SimklIds; + // For episodes + season?: number; + episode?: number; + showTitle?: string; + // For anime + animeType?: string; +} + +export interface SimklScrobbleResponse { + id: number; + action: 'start' | 'pause' | 'scrobble'; + progress: number; + movie?: any; + show?: any; + episode?: any; + anime?: any; +} + +export interface SimklPlaybackData { + id: number; + progress: number; + paused_at: string; + type: 'movie' | 'episode'; + movie?: { + title: string; + year: number; + ids: SimklIds; + }; + show?: { + title: string; + year: number; + ids: SimklIds; + }; + episode?: { + season: number; + episode: number; + title: string; + tvdb_season?: number; + tvdb_number?: number; + }; +} + +export class SimklService { + private static instance: SimklService; + private accessToken: string | null = null; + private isInitialized: boolean = false; + + // Rate limiting & Debouncing + private lastApiCall: number = 0; + private readonly MIN_API_INTERVAL = 500; + private requestQueue: Array<() => Promise> = []; + private isProcessingQueue: boolean = false; + + // Track scrobbled items to prevent duplicates/spam + private lastSyncTimes: Map = new Map(); + private readonly SYNC_DEBOUNCE_MS = 15000; // 15 seconds + + // Default completion threshold (can't be configured on Simkl side essentially, but we use it for logic) + private readonly COMPLETION_THRESHOLD = 80; + + private constructor() { + // Determine cleanup logic if needed + AppState.addEventListener('change', this.handleAppStateChange); + } + + public static getInstance(): SimklService { + if (!SimklService.instance) { + SimklService.instance = new SimklService(); + } + return SimklService.instance; + } + + private handleAppStateChange = (nextAppState: AppStateStatus) => { + // Potential cleanup or flush queue logic here + }; + + /** + * Initialize the Simkl service by loading stored token + */ + public async initialize(): Promise { + if (this.isInitialized) return; + + try { + const accessToken = await mmkvStorage.getItem(SIMKL_ACCESS_TOKEN_KEY); + this.accessToken = accessToken; + this.isInitialized = true; + logger.log('[SimklService] Initialized, authenticated:', !!this.accessToken); + } catch (error) { + logger.error('[SimklService] Initialization failed:', error); + throw error; + } + } + + /** + * Check if the user is authenticated + */ + public async isAuthenticated(): Promise { + await this.ensureInitialized(); + return !!this.accessToken; + } + + /** + * Get auth URL for OAuth + */ + public getAuthUrl(): string { + return `https://simkl.com/oauth/authorize?response_type=code&client_id=${SIMKL_CLIENT_ID}&redirect_uri=${encodeURIComponent(SIMKL_REDIRECT_URI)}`; + } + + /** + * Exchange code for access token + * Simkl tokens do not expire + */ + public async exchangeCodeForToken(code: string): Promise { + await this.ensureInitialized(); + + try { + const response = await fetch(`${SIMKL_API_URL}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + code, + client_id: SIMKL_CLIENT_ID, + client_secret: SIMKL_CLIENT_SECRET, + redirect_uri: SIMKL_REDIRECT_URI, + grant_type: 'authorization_code' + }) + }); + + if (!response.ok) { + const errorBody = await response.text(); + logger.error('[SimklService] Token exchange error:', errorBody); + return false; + } + + const data = await response.json(); + if (data.access_token) { + await this.saveToken(data.access_token); + return true; + } + return false; + } catch (error) { + logger.error('[SimklService] Failed to exchange code:', error); + return false; + } + } + + private async saveToken(accessToken: string): Promise { + this.accessToken = accessToken; + try { + await mmkvStorage.setItem(SIMKL_ACCESS_TOKEN_KEY, accessToken); + logger.log('[SimklService] Token saved successfully'); + } catch (error) { + logger.error('[SimklService] Failed to save token:', error); + throw error; + } + } + + public async logout(): Promise { + await this.ensureInitialized(); + this.accessToken = null; + await mmkvStorage.removeItem(SIMKL_ACCESS_TOKEN_KEY); + logger.log('[SimklService] Logged out'); + } + + private async ensureInitialized(): Promise { + if (!this.isInitialized) { + await this.initialize(); + } + } + + /** + * Base API Request handler + */ + private async apiRequest( + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', + body?: any + ): Promise { + await this.ensureInitialized(); + + // Rate limiting + const now = Date.now(); + const timeSinceLastCall = now - this.lastApiCall; + if (timeSinceLastCall < this.MIN_API_INTERVAL) { + await new Promise(resolve => setTimeout(resolve, this.MIN_API_INTERVAL - timeSinceLastCall)); + } + this.lastApiCall = Date.now(); + + if (!this.accessToken) { + logger.warn('[SimklService] Cannot make request: Not authenticated'); + return null; + } + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.accessToken}`, + 'simkl-api-key': SIMKL_CLIENT_ID + }; + + const options: RequestInit = { + method, + headers + }; + + if (body) { + options.body = JSON.stringify(body); + } + + if (endpoint.includes('scrobble')) { + logger.log(`[SimklService] Requesting: ${method} ${endpoint}`, body); + } + + try { + const response = await fetch(`${SIMKL_API_URL}${endpoint}`, options); + + if (response.status === 409) { + // Conflict means already watched/scrobbled within last hour, which is strictly a success for our purposes + logger.log(`[SimklService] 409 Conflict (Already watched/active) for ${endpoint}`); + // We can return a mock success or null depending on what caller expects. + // For scrobble actions (which usually return an ID or object), we might return null or handle it. + // Simkl returns body with "watched_at" etc. + return null; + } + + if (!response.ok) { + const errorText = await response.text(); + logger.error(`[SimklService] API Error ${response.status} for ${endpoint}:`, errorText); + return null; // Return null on error + } + + // Handle 204 No Content + if (response.status === 204) { + return {} as T; + } + + return await response.json(); + } catch (error) { + logger.error(`[SimklService] Network request failed for ${endpoint}:`, error); + throw error; + } + } + + /** + * Build payload for Scrobbling + */ + private buildScrobblePayload(content: SimklContentData, progress: number): any { + // Simkl uses flexible progress but let's standardize + const cleanProgress = Math.max(0, Math.min(100, progress)); + + const payload: any = { + progress: cleanProgress + }; + + // IDs object setup (remove undefined/nulls) + const ids: any = {}; + if (content.ids.imdb) ids.imdb = content.ids.imdb; + if (content.ids.tmdb) ids.tmdb = content.ids.tmdb; + if (content.ids.simkl) ids.simkl = content.ids.simkl; + if (content.ids.mal) ids.mal = content.ids.mal; // for anime + + // Construct object based on type + if (content.type === 'movie') { + payload.movie = { + title: content.title, + year: content.year, + ids: ids + }; + } else if (content.type === 'episode') { + payload.show = { + title: content.showTitle || content.title, + year: content.year, + ids: { + // If we have show IMDB/TMDB use those, otherwise fallback (might be same if passed in ids) + // Ideally caller passes show-specific IDs in ids, but often we just have ids for the general item + imdb: content.ids.imdb, + tmdb: content.ids.tmdb, + simkl: content.ids.simkl + } + }; + payload.episode = { + season: content.season, + number: content.episode + }; + } else if (content.type === 'anime') { + payload.anime = { + title: content.title, + ids: ids + }; + // Anime also needs episode info if it's an episode + if (content.episode) { + payload.episode = { + season: content.season || 1, + number: content.episode + }; + } + } + + return payload; + } + + /** + * SCROBBLE: START + */ + public async scrobbleStart(content: SimklContentData, progress: number): Promise { + try { + const payload = this.buildScrobblePayload(content, progress); + logger.log('[SimklService] scrobbleStart payload:', JSON.stringify(payload)); + const response = await this.apiRequest('/scrobble/start', 'POST', payload); + logger.log('[SimklService] scrobbleStart response:', JSON.stringify(response)); + return response; + } catch (e) { + logger.error('[SimklService] Scrobble Start failed', e); + return null; + } + } + + /** + * SCROBBLE: PAUSE + */ + public async scrobblePause(content: SimklContentData, progress: number): Promise { + try { + // Debounce check + const key = this.getContentKey(content); + const now = Date.now(); + const lastSync = this.lastSyncTimes.get(key) || 0; + + if (now - lastSync < this.SYNC_DEBOUNCE_MS) { + return null; // Skip if too soon + } + this.lastSyncTimes.set(key, now); + + this.lastSyncTimes.set(key, now); + + const payload = this.buildScrobblePayload(content, progress); + logger.log('[SimklService] scrobblePause payload:', JSON.stringify(payload)); + const response = await this.apiRequest('/scrobble/pause', 'POST', payload); + logger.log('[SimklService] scrobblePause response:', JSON.stringify(response)); + return response; + } catch (e) { + logger.error('[SimklService] Scrobble Pause failed', e); + return null; + } + } + + /** + * SCROBBLE: STOP + */ + public async scrobbleStop(content: SimklContentData, progress: number): Promise { + try { + const payload = this.buildScrobblePayload(content, progress); + logger.log('[SimklService] scrobbleStop payload:', JSON.stringify(payload)); + // Simkl automatically marks as watched if progress >= 80% (or server logic) + // We just hit /scrobble/stop + const response = await this.apiRequest('/scrobble/stop', 'POST', payload); + logger.log('[SimklService] scrobbleStop response:', JSON.stringify(response)); + + // If response is null (often 409 Conflict) OR we failed, but progress is high, + // we should force "mark as watched" via history sync to be safe. + // 409 means "Action already active" or "Checkin active", often if 'pause' was just called. + // If the user finished (progress >= 80), we MUST ensure it's marked watched. + if (!response && progress >= this.COMPLETION_THRESHOLD) { + logger.log(`[SimklService] scrobbleStop failed/conflict at ${progress}%. Falling back to /sync/history to ensure watched status.`); + + try { + const historyPayload: any = {}; + + if (content.type === 'movie') { + historyPayload.movies = [{ + ids: content.ids + }]; + } else if (content.type === 'episode') { + historyPayload.shows = [{ + ids: content.ids, + seasons: [{ + number: content.season, + episodes: [{ number: content.episode }] + }] + }]; + } else if (content.type === 'anime') { + // Anime structure similar to shows usually, or 'anime' key? + // Simkl API often uses 'shows' for anime too if listed as show, or 'anime' key. + // Safest is to try 'shows' if we have standard IDs, or 'anime' if specifically anime. + // Let's use 'anime' key if type is anime, assuming similar structure. + historyPayload.anime = [{ + ids: content.ids, + episodes: [{ + season: content.season || 1, + number: content.episode + }] + }]; + } + + if (Object.keys(historyPayload).length > 0) { + const historyResponse = await this.addToHistory(historyPayload); + logger.log('[SimklService] Fallback history sync response:', JSON.stringify(historyResponse)); + if (historyResponse) { + // Construct a fake scrobble response to satisfy caller + return { + id: 0, + action: 'scrobble', + progress: progress, + ...payload + } as SimklScrobbleResponse; + } + } + } catch (err) { + logger.error('[SimklService] Fallback history sync failed:', err); + } + } + + return response; + } catch (e) { + logger.error('[SimklService] Scrobble Stop failed', e); + return null; + } + } + + private getContentKey(content: SimklContentData): string { + return `${content.type}:${content.ids.imdb || content.ids.tmdb || content.title}:${content.season}:${content.episode}`; + } + + /** + * SYNC: Get Playback Sessions (Continue Watching) + */ + /** + * SYNC: Add items to History (Global "Mark as Watched") + */ + public async addToHistory(items: { movies?: any[], shows?: any[], episodes?: any[] }): Promise { + return await this.apiRequest('/sync/history', 'POST', items); + } + + /** + * SYNC: Remove items from History + */ + public async removeFromHistory(items: { movies?: any[], shows?: any[], episodes?: any[] }): Promise { + return await this.apiRequest('/sync/history/remove', 'POST', items); + } + + public async getPlaybackStatus(): Promise { + // Get both movies and episodes + // Simkl endpoint: /sync/playback (returns all if no type specified, or we specify type) + // Docs say /sync/playback/{type} + // Let's trying getting all if possible, or fetch both. Docs say type is optional param? + // Docs: /sync/playback/{type} -> actually path param seems required or at least standard. + // But query params: type (optional). + // Let's try fetching without path param or empty? + // Docs: "Retrieves all paused... optionally filter by type by appending /movie" + // Let's assume /sync/playback works for all. + + const response = await this.apiRequest('/sync/playback'); + return response || []; + } + + /** + * SYNC: Get Full Watch History (summary) + * Optimization: Check /sync/activities first in real usage. + * For now, we implement simple fetch. + */ + public async getAllItems(dateFrom?: string): Promise { + let url = '/sync/all-items/'; + if (dateFrom) { + url += `?date_from=${dateFrom}`; + } + return await this.apiRequest(url); + } +} diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 70946f2a..bd3b1484 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -9,6 +9,9 @@ interface WatchProgress { traktSynced?: boolean; traktLastSynced?: number; traktProgress?: number; + simklSynced?: boolean; + simklLastSynced?: number; + simklProgress?: number; } class StorageService { @@ -463,6 +466,46 @@ class StorageService { } } + /** + * Update Simkl sync status for a watch progress entry + */ + public async updateSimklSyncStatus( + id: string, + type: string, + simklSynced: boolean, + simklProgress?: number, + episodeId?: string, + exactTime?: number + ): Promise { + try { + const existingProgress = await this.getWatchProgress(id, type, episodeId); + if (existingProgress) { + // Preserve the highest Simkl progress and currentTime values + const highestSimklProgress = (() => { + if (simklProgress === undefined) return existingProgress.simklProgress; + if (existingProgress.simklProgress === undefined) return simklProgress; + return Math.max(simklProgress, existingProgress.simklProgress); + })(); + + const highestCurrentTime = (() => { + if (!exactTime || exactTime <= 0) return existingProgress.currentTime; + return Math.max(exactTime, existingProgress.currentTime); + })(); + + const updatedProgress: WatchProgress = { + ...existingProgress, + simklSynced, + simklLastSynced: simklSynced ? Date.now() : existingProgress.simklLastSynced, + simklProgress: highestSimklProgress, + currentTime: highestCurrentTime, + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + } + } catch (error) { + logger.error('Error updating Simkl sync status:', error); + } + } + /** * Get all watch progress entries that need Trakt sync */ @@ -495,8 +538,8 @@ class StorageService { continue; } // Check if needs sync (either never synced or local progress is newer) - const needsSync = !progress.traktSynced || - (progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced); + const needsSync = (!progress.traktSynced || (progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced)) || + (!progress.simklSynced || (progress.simklLastSynced && progress.lastUpdated > progress.simklLastSynced)); if (needsSync) { const parts = key.split(':'); @@ -611,6 +654,7 @@ class StorageService { duration, lastUpdated: traktTimestamp, traktSynced: true, + simklSynced: false, traktLastSynced: Date.now(), traktProgress }; @@ -687,6 +731,105 @@ class StorageService { } } + /** + * Merge Simkl progress with local progress using exact time when available + */ + public async mergeWithSimklProgress( + id: string, + type: string, + simklProgress: number, + simklPausedAt: string, + episodeId?: string, + exactTime?: number + ): Promise { + try { + const localProgress = await this.getWatchProgress(id, type, episodeId); + const simklTimestamp = new Date(simklPausedAt).getTime(); + + if (!localProgress) { + let duration = await this.getContentDuration(id, type, episodeId); + let currentTime: number; + + if (exactTime && exactTime > 0) { + currentTime = exactTime; + if (!duration) { + duration = (exactTime / simklProgress) * 100; + } + } else { + if (!duration) { + if (type === 'movie') { + duration = 6600; + } else if (episodeId) { + duration = 2700; + } else { + duration = 3600; + } + } + currentTime = (simklProgress / 100) * duration; + } + + const newProgress: WatchProgress = { + currentTime, + duration, + lastUpdated: simklTimestamp, + simklSynced: true, + simklLastSynced: Date.now(), + simklProgress + }; + await this.setWatchProgress(id, type, newProgress, episodeId); + } else { + const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100; + const progressDiff = Math.abs(simklProgress - localProgressPercent); + + if (progressDiff < 5 && simklProgress < 100 && localProgressPercent < 100) { + return; + } + + let currentTime: number; + let duration = localProgress.duration; + + if (exactTime && exactTime > 0 && localProgress.duration > 0) { + currentTime = exactTime; + const calculatedDuration = (exactTime / simklProgress) * 100; + if (Math.abs(calculatedDuration - localProgress.duration) > 300) { + duration = calculatedDuration; + } + } else if (localProgress.duration > 0) { + currentTime = (simklProgress / 100) * localProgress.duration; + } else { + const storedDuration = await this.getContentDuration(id, type, episodeId); + duration = storedDuration || 0; + if (!duration || duration <= 0) { + if (exactTime && exactTime > 0) { + duration = (exactTime / simklProgress) * 100; + currentTime = exactTime; + } else { + if (type === 'movie') duration = 6600; + else if (episodeId) duration = 2700; + else duration = 3600; + currentTime = (simklProgress / 100) * duration; + } + } else { + currentTime = exactTime && exactTime > 0 ? exactTime : (simklProgress / 100) * duration; + } + } + + const updatedProgress: WatchProgress = { + ...localProgress, + currentTime, + duration, + lastUpdated: simklTimestamp, + simklSynced: true, + simklLastSynced: Date.now(), + simklProgress + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + } + } catch (error) { + logger.error('Error merging with Simkl progress:', error); + } + } + public async saveSubtitleSettings(settings: Record): Promise { try { const key = await this.getSubtitleSettingsKeyScoped(); diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 4bfca17d..50218aeb 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -1,4 +1,5 @@ import { TraktService } from './traktService'; +import { SimklService } from './simklService'; import { storageService } from './storageService'; import { mmkvStorage } from './mmkvStorage'; import { logger } from '../utils/logger'; @@ -13,9 +14,11 @@ import { logger } from '../utils/logger'; class WatchedService { private static instance: WatchedService; private traktService: TraktService; + private simklService: SimklService; private constructor() { this.traktService = TraktService.getInstance(); + this.simklService = SimklService.getInstance(); } public static getInstance(): WatchedService { @@ -47,6 +50,13 @@ class WatchedService { logger.log(`[WatchedService] Trakt sync result for movie: ${syncedToTrakt}`); } + // Sync to Simkl + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + await this.simklService.addToHistory({ movies: [{ ids: { imdb: imdbId }, watched_at: watchedAt.toISOString() }] }); + logger.log(`[WatchedService] Simkl sync request sent for movie`); + } + // Also store locally as "completed" (100% progress) await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt); @@ -90,6 +100,22 @@ class WatchedService { logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`); } + // Sync to Simkl + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + // Simkl structure: shows -> seasons -> episodes + await this.simklService.addToHistory({ + shows: [{ + ids: { imdb: showImdbId }, + seasons: [{ + number: season, + episodes: [{ number: episode, watched_at: watchedAt.toISOString() }] + }] + }] + }); + logger.log(`[WatchedService] Simkl sync request sent for episode`); + } + // Store locally as "completed" const episodeId = `${showId}:${season}:${episode}`; await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt); @@ -135,6 +161,27 @@ class WatchedService { logger.log(`[WatchedService] Trakt batch sync result: ${syncedToTrakt}`); } + // Sync to Simkl + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + // Group by season for Simkl payload efficiency + const seasonMap = new Map(); + episodes.forEach(ep => { + 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(([num, eps]) => ({ number: num, episodes: eps })); + + await this.simklService.addToHistory({ + shows: [{ + ids: { imdb: showImdbId }, + seasons: seasons + }] + }); + logger.log(`[WatchedService] Simkl batch sync request sent`); + } + // Store locally as "completed" for each episode for (const ep of episodes) { const episodeId = `${showId}:${ep.season}:${ep.episode}`; @@ -180,6 +227,24 @@ class WatchedService { logger.log(`[WatchedService] Trakt season sync result: ${syncedToTrakt}`); } + // Sync to Simkl + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + // Simkl doesn't have a direct "mark season" generic endpoint in the same way, but we can construct it + // We know the episodeNumbers from the arguments! + const episodes = episodeNumbers.map(num => ({ number: num, watched_at: watchedAt.toISOString() })); + await this.simklService.addToHistory({ + shows: [{ + ids: { imdb: showImdbId }, + seasons: [{ + number: season, + episodes: episodes + }] + }] + }); + logger.log(`[WatchedService] Simkl season sync request sent`); + } + // Store locally as "completed" for each episode in the season for (const epNum of episodeNumbers) { const episodeId = `${showId}:${season}:${epNum}`; @@ -210,6 +275,13 @@ class WatchedService { logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`); } + // Simkl Unmark + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + await this.simklService.removeFromHistory({ movies: [{ ids: { imdb: imdbId } }] }); + logger.log(`[WatchedService] Simkl remove request sent for movie`); + } + // Remove local progress await storageService.removeWatchProgress(imdbId, 'movie'); await mmkvStorage.removeItem(`watched:movie:${imdbId}`); @@ -245,6 +317,21 @@ class WatchedService { logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`); } + // Simkl Unmark + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + await this.simklService.removeFromHistory({ + shows: [{ + ids: { imdb: showImdbId }, + seasons: [{ + number: season, + episodes: [{ number: episode }] + }] + }] + }); + logger.log(`[WatchedService] Simkl remove request sent for episode`); + } + // Remove local progress const episodeId = `${showId}:${season}:${episode}`; await storageService.removeWatchProgress(showId, 'series', episodeId); @@ -281,9 +368,29 @@ class WatchedService { showImdbId, season ); + syncedToTrakt = await this.traktService.removeSeasonFromHistory( + showImdbId, + season + ); logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`); } + // Sync to Simkl + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + const episodes = episodeNumbers.map(num => ({ number: num })); + await this.simklService.removeFromHistory({ + shows: [{ + ids: { imdb: showImdbId }, + seasons: [{ + number: season, + episodes: episodes + }] + }] + }); + logger.log(`[WatchedService] Simkl season removal request sent`); + } + // Remove local progress for each episode in the season for (const epNum of episodeNumbers) { const episodeId = `${showId}:${season}:${epNum}`; @@ -301,60 +408,60 @@ class WatchedService { * Check if a movie is marked as watched (locally) */ public async isMovieWatched(imdbId: string): Promise { - try { - const isAuthed = await this.traktService.isAuthenticated(); + try { + const isAuthed = await this.traktService.isAuthenticated(); - if (isAuthed) { - const traktWatched = - await this.traktService.isMovieWatchedAccurate(imdbId); - if (traktWatched) return true; + if (isAuthed) { + const traktWatched = + await this.traktService.isMovieWatchedAccurate(imdbId); + if (traktWatched) return true; + } + + const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`); + return local === 'true'; + } catch { + return false; } - - const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`); - return local === 'true'; - } catch { - return false; - } } - + /** * Check if an episode is marked as watched (locally) */ public async isEpisodeWatched( - showId: string, - season: number, - episode: number + showId: string, + season: number, + episode: number ): Promise { - try { - const isAuthed = await this.traktService.isAuthenticated(); + try { + const isAuthed = await this.traktService.isAuthenticated(); - if (isAuthed) { - const traktWatched = - await this.traktService.isEpisodeWatchedAccurate( - showId, - season, - episode + if (isAuthed) { + const traktWatched = + await this.traktService.isEpisodeWatchedAccurate( + showId, + season, + episode + ); + if (traktWatched) return true; + } + + const episodeId = `${showId}:${season}:${episode}`; + const progress = await storageService.getWatchProgress( + showId, + 'series', + episodeId ); - if (traktWatched) return true; + + if (!progress) return false; + + const pct = (progress.currentTime / progress.duration) * 100; + return pct >= 99; + } catch { + return false; } - - const episodeId = `${showId}:${season}:${episode}`; - const progress = await storageService.getWatchProgress( - showId, - 'series', - episodeId - ); - - if (!progress) return false; - - const pct = (progress.currentTime / progress.duration) * 100; - return pct >= 99; - } catch { - return false; - } } - + /** * Set local watched status by creating a "completed" progress entry */