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/LibTorrent b/LibTorrent new file mode 160000 index 00000000..eb1c7139 --- /dev/null +++ b/LibTorrent @@ -0,0 +1 @@ +Subproject commit eb1c71397b8716b97fcd375fd646e96c89632a5e diff --git a/assets/simkl-favicon.png b/assets/simkl-favicon.png new file mode 100644 index 00000000..c8454b3e Binary files /dev/null and b/assets/simkl-favicon.png differ diff --git a/assets/simkl-logo.png b/assets/simkl-logo.png new file mode 100644 index 00000000..6836cfc0 Binary files /dev/null and b/assets/simkl-logo.png differ diff --git a/assets/trakt-favicon.png b/assets/trakt-favicon.png new file mode 100644 index 00000000..e69de29b diff --git a/iTorrent b/iTorrent new file mode 160000 index 00000000..c27088b0 --- /dev/null +++ b/iTorrent @@ -0,0 +1 @@ +Subproject commit c27088b0ac36bf9bb30fae34dc36db1231263bfd diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index ff0f2996..30e41a5a 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -28,6 +28,7 @@ import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; import * as Haptics from 'expo-haptics'; import { TraktService } from '../../services/traktService'; +import { SimklService } from '../../services/simklService'; import { stremioService } from '../../services/stremioService'; import { streamCacheService } from '../../services/streamCacheService'; import { useSettings } from '../../hooks/useSettings'; @@ -221,6 +222,10 @@ const ContinueWatchingSection = React.forwardRef((props, re const lastTraktSyncRef = useRef(0); const TRAKT_SYNC_COOLDOWN = 0; // disabled (always fetch Trakt playback) + // Track last Simkl sync to prevent excessive API calls + const lastSimklSyncRef = useRef(0); + const SIMKL_SYNC_COOLDOWN = 0; // disabled (always fetch Simkl playback) + // Track last Trakt reconcile per item (local -> Trakt catch-up) const lastTraktReconcileRef = useRef>(new Map()); const TRAKT_RECONCILE_COOLDOWN = 0; // 2 minutes between reconcile attempts per item @@ -471,13 +476,19 @@ const ContinueWatchingSection = React.forwardRef((props, re const traktService = TraktService.getInstance(); const isTraktAuthed = await traktService.isAuthenticated(); + const simklService = SimklService.getInstance(); + // Prefer Trakt if both are authenticated + const isSimklAuthed = !isTraktAuthed ? await simklService.isAuthenticated() : false; + + logger.log(`[CW] Providers authed: trakt=${isTraktAuthed} simkl=${isSimklAuthed}`); + // Declare groupPromises outside the if block let groupPromises: Promise[] = []; // In Trakt mode, CW is sourced from Trakt only, but we still want to overlay local progress // when local is ahead (scrobble lag/offline playback). let localProgressIndex: Map | null = null; - if (isTraktAuthed) { + if (isTraktAuthed || isSimklAuthed) { try { const allProgress = await storageService.getAllWatchProgress(); const index = new Map(); @@ -519,8 +530,8 @@ const ContinueWatchingSection = React.forwardRef((props, re } } - // Non-Trakt: use local storage - if (!isTraktAuthed) { + // Local-only mode (no Trakt, no Simkl): use local storage + if (!isTraktAuthed && !isSimklAuthed) { const allProgress = await storageService.getAllWatchProgress(); if (Object.keys(allProgress).length === 0) { setContinueWatchingItems([]); @@ -1300,8 +1311,219 @@ const ContinueWatchingSection = React.forwardRef((props, re } })(); - // Wait for all groups and trakt merge to settle, then finalize loading state - await Promise.allSettled([...groupPromises, traktMergePromise]); + // SIMKL: fetch playback progress (in-progress, paused) and merge similarly to Trakt + const simklMergePromise = (async () => { + try { + if (!isSimklAuthed || isTraktAuthed) return; + + const now = Date.now(); + if (SIMKL_SYNC_COOLDOWN > 0 && (now - lastSimklSyncRef.current) < SIMKL_SYNC_COOLDOWN) { + return; + } + lastSimklSyncRef.current = now; + + const playbackItems = await simklService.getPlaybackStatus(); + logger.log(`[CW][Simkl] playback items: ${playbackItems.length}`); + + const simklBatch: ContinueWatchingItem[] = []; + const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); + + const sortedPlaybackItems = [...playbackItems] + .sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime()) + .slice(0, 30); + + for (const item of sortedPlaybackItems) { + try { + // Skip accidental clicks + if ((item.progress ?? 0) < 2) continue; + + const pausedAt = new Date(item.paused_at).getTime(); + if (pausedAt < thirtyDaysAgo) continue; + + if (item.type === 'movie' && item.movie?.ids?.imdb) { + // Skip completed movies + if (item.progress >= 85) continue; + + const imdbId = item.movie.ids.imdb.startsWith('tt') + ? item.movie.ids.imdb + : `tt${item.movie.ids.imdb}`; + + const movieKey = `movie:${imdbId}`; + if (recentlyRemovedRef.current.has(movieKey)) continue; + + const cachedData = await getCachedMetadata('movie', imdbId); + if (!cachedData?.basicContent) continue; + + simklBatch.push({ + ...cachedData.basicContent, + id: imdbId, + type: 'movie', + progress: item.progress, + lastUpdated: pausedAt, + addonId: undefined, + } as ContinueWatchingItem); + } else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) { + const showImdb = item.show.ids.imdb.startsWith('tt') + ? item.show.ids.imdb + : `tt${item.show.ids.imdb}`; + + const episodeNum = (item.episode as any).episode ?? (item.episode as any).number; + if (episodeNum === undefined || episodeNum === null) { + logger.warn('[CW][Simkl] Missing episode number in playback item, skipping', item); + continue; + } + + const showKey = `series:${showImdb}`; + if (recentlyRemovedRef.current.has(showKey)) continue; + + const cachedData = await getCachedMetadata('series', showImdb); + if (!cachedData?.basicContent) continue; + + // If episode is completed (>= 85%), find next episode + if (item.progress >= 85) { + const metadata = cachedData.metadata; + if (metadata?.videos) { + const nextEpisode = findNextEpisode( + item.episode.season, + episodeNum, + metadata.videos, + undefined, + showImdb + ); + + if (nextEpisode) { + simklBatch.push({ + ...cachedData.basicContent, + id: showImdb, + type: 'series', + progress: 0, + lastUpdated: pausedAt, + season: nextEpisode.season, + episode: nextEpisode.episode, + episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, + addonId: undefined, + } as ContinueWatchingItem); + } + } + continue; + } + + simklBatch.push({ + ...cachedData.basicContent, + id: showImdb, + type: 'series', + progress: item.progress, + lastUpdated: pausedAt, + season: item.episode.season, + episode: episodeNum, + episodeTitle: item.episode.title || `Episode ${episodeNum}`, + addonId: undefined, + } as ContinueWatchingItem); + } + } catch { + // Continue with other items + } + } + + if (simklBatch.length === 0) { + setContinueWatchingItems([]); + return; + } + + // Dedupe (keep most recent per show/movie) + const deduped = new Map(); + for (const item of simklBatch) { + const key = `${item.type}:${item.id}`; + const existing = deduped.get(key); + if (!existing || (item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { + deduped.set(key, item); + } + } + + // Filter removed items + const filteredItems: ContinueWatchingItem[] = []; + for (const item of deduped.values()) { + const key = item.type === 'series' && item.season && item.episode + ? `${item.type}:${item.id}:${item.season}:${item.episode}` + : `${item.type}:${item.id}`; + if (recentlyRemovedRef.current.has(key)) continue; + + const removeId = item.type === 'series' && item.season && item.episode + ? `${item.id}:${item.season}:${item.episode}` + : item.id; + const isRemoved = await storageService.isContinueWatchingRemoved(removeId, item.type); + if (!isRemoved) filteredItems.push(item); + } + + // Overlay local progress when local is ahead or newer + const adjustedItems = filteredItems.map((it) => { + if (!localProgressIndex) return it; + + const matches: LocalProgressEntry[] = []; + for (const idVariant of getIdVariants(it.id)) { + const list = localProgressIndex.get(`${it.type}:${idVariant}`); + if (!list) continue; + for (const entry of list) { + if (it.type === 'series' && it.season !== undefined && it.episode !== undefined) { + if (entry.season === it.season && entry.episode === it.episode) { + matches.push(entry); + } + } else { + matches.push(entry); + } + } + } + + if (matches.length === 0) return it; + + const mostRecentLocal = matches.reduce((acc, cur) => { + if (!acc) return cur; + return (cur.lastUpdated ?? 0) > (acc.lastUpdated ?? 0) ? cur : acc; + }, null); + + const highestLocal = matches.reduce((acc, cur) => { + if (!acc) return cur; + return (cur.progressPercent ?? 0) > (acc.progressPercent ?? 0) ? cur : acc; + }, null); + + if (!mostRecentLocal || !highestLocal) return it; + + const localProgress = mostRecentLocal.progressPercent; + const simklProgress = it.progress ?? 0; + const localTs = mostRecentLocal.lastUpdated ?? 0; + const simklTs = it.lastUpdated ?? 0; + + const isAhead = isFinite(localProgress) && localProgress > simklProgress + 0.5; + const isLocalNewer = localTs > simklTs + 5000; + + if (isAhead || isLocalNewer) { + return { + ...it, + progress: localProgress, + lastUpdated: localTs > 0 ? localTs : it.lastUpdated, + } as ContinueWatchingItem; + } + + // Otherwise keep Simkl, but if local has a newer timestamp, use it for ordering + if (localTs > 0 && localTs > simklTs) { + return { + ...it, + lastUpdated: localTs, + } as ContinueWatchingItem; + } + + return it; + }); + + adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + setContinueWatchingItems(adjustedItems); + } catch (err) { + logger.error('[SimklSync] Error in Simkl merge:', err); + } + })(); + + // Wait for all groups and provider merges to settle, then finalize loading state + await Promise.allSettled([...groupPromises, traktMergePromise, simklMergePromise]); } catch (error) { // Continue even if loading fails } finally { diff --git a/src/components/icons/SimklIcon.tsx b/src/components/icons/SimklIcon.tsx new file mode 100644 index 00000000..0d310e23 --- /dev/null +++ b/src/components/icons/SimklIcon.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { Image, StyleSheet } from 'react-native'; + +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/components/icons/TraktIcon.tsx b/src/components/icons/TraktIcon.tsx index 65e85c73..34ae41be 100644 --- a/src/components/icons/TraktIcon.tsx +++ b/src/components/icons/TraktIcon.tsx @@ -5,14 +5,15 @@ import Svg, { Path } from 'react-native-svg'; interface TraktIconProps { size?: number; color?: string; + style?: any; } -const TraktIcon: React.FC = ({ size = 24, color = '#ed2224' }) => { +const TraktIcon: React.FC = ({ size = 24, color = '#ed2224', style }) => { return ( - + { initialPosition: routeInitialPosition } = params; + const videoType = (params as any)?.videoType as string | undefined; + + useEffect(() => { + if (!__DEV__) return; + const headerKeys = Object.keys(headers || {}); + logger.log('[KSPlayerCore] route params', { + uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, + id, + type, + episodeId, + imdbId, + title, + episodeTitle, + season, + episode, + quality, + year, + streamProvider, + streamName, + videoType, + headersKeys: headerKeys, + headersCount: headerKeys.length, + }); + }, [uri, episodeId]); + + useEffect(() => { + if (!__DEV__) return; + const headerKeys = Object.keys(headers || {}); + logger.log('[KSPlayerCore] source update', { + uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, + videoType, + headersCount: headerKeys.length, + headersKeys: headerKeys, + }); + }, [uri, headers, videoType]); + // --- Hooks --- const playerState = usePlayerState(); const { @@ -399,6 +436,17 @@ const KSPlayerCore: React.FC = () => { // Handlers const onLoad = (data: any) => { + if (__DEV__) { + logger.log('[KSPlayerCore] onLoad', { + uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, + duration: data?.duration, + audioTracksCount: Array.isArray(data?.audioTracks) ? data.audioTracks.length : 0, + textTracksCount: Array.isArray(data?.textTracks) ? data.textTracks.length : 0, + videoType, + headersKeys: Object.keys(headers || {}), + }); + } + setDuration(data.duration); if (data.audioTracks) tracks.setKsAudioTracks(data.audioTracks); if (data.textTracks) tracks.setKsTextTracks(data.textTracks); @@ -482,6 +530,18 @@ const KSPlayerCore: React.FC = () => { } catch (e) { msg = 'Error parsing error details'; } + + if (__DEV__) { + logger.error('[KSPlayerCore] onError', { + msg, + uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, + videoType, + streamProvider, + streamName, + headersKeys: Object.keys(headers || {}), + rawError: error, + }); + } modals.setErrorDetails(msg); modals.setShowErrorModal(true); }; @@ -525,6 +585,17 @@ const KSPlayerCore: React.FC = () => { modals.setShowSourcesModal(false); return; } + + if (__DEV__) { + logger.log('[KSPlayerCore] switching stream', { + fromUri: typeof uri === 'string' ? uri.slice(0, 240) : uri, + toUri: typeof newStream?.url === 'string' ? newStream.url.slice(0, 240) : newStream?.url, + newStreamHeadersKeys: Object.keys(newStream?.headers || {}), + newProvider: newStream?.addonName || newStream?.name || newStream?.addon || 'Unknown', + newName: newStream?.name || newStream?.title || 'Unknown', + }); + } + modals.setShowSourcesModal(false); setPaused(true); @@ -559,6 +630,19 @@ const KSPlayerCore: React.FC = () => { setPaused(true); const ep = modals.selectedEpisodeForStreams; + if (__DEV__) { + logger.log('[KSPlayerCore] switching episode stream', { + toUri: typeof stream?.url === 'string' ? stream.url.slice(0, 240) : stream?.url, + streamHeadersKeys: Object.keys(stream?.headers || {}), + ep: { + season: ep?.season_number, + episode: ep?.episode_number, + name: ep?.name, + stremioId: ep?.stremioId, + }, + }); + } + const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]); const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown'; const newStreamName = stream.name || stream.title || 'Unknown Stream'; diff --git a/src/hooks/useSimklIntegration.ts b/src/hooks/useSimklIntegration.ts new file mode 100644 index 00000000..2e2f19bd --- /dev/null +++ b/src/hooks/useSimklIntegration.ts @@ -0,0 +1,250 @@ +import { useState, useEffect, useCallback } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; +import { + SimklService, + SimklContentData, + SimklPlaybackData, + SimklUserSettings, + SimklStats +} 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([]); + const [userSettings, setUserSettings] = useState(null); + const [userStats, setUserStats] = useState(null); + + // 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]); + + // Load user settings and stats + const loadUserProfile = useCallback(async () => { + if (!isAuthenticated) return; + try { + const settings = await simklService.getUserSettings(); + setUserSettings(settings); + + const stats = await simklService.getUserStats(); + setUserStats(stats); + } catch (error) { + logger.error('[useSimklIntegration] Error loading user profile:', 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(); + logger.log(`[useSimklIntegration] fetched Simkl playback: ${playback.length}`); + + 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'; + const epNum = (item.episode as any).episode ?? (item.episode as any).number; + episodeId = epNum !== undefined ? `${id}:${item.episode.season}:${epNum}` : undefined; + } + + 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(); + loadUserProfile(); + } + }, [isAuthenticated, loadPlaybackStatus, fetchAndMergeSimklProgress, loadUserProfile]); + + // 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, + userSettings, + userStats, + }; +} 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/i18n/locales/ar.json b/src/i18n/locales/ar.json index 623d4c06..44bc4fbc 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -522,6 +522,27 @@ "sync_success_msg": "تمت مزامنة تقدم المشاهدة مع Trakt بنجاح.", "sync_error_msg": "فشلت المزامنة. يرجى المحاولة مرة أخرى." }, + "simkl": { + "title": "إعدادات Simkl", + "settings_title": "إعدادات Simkl", + "connect_title": "الاتصال بـ Simkl", + "connect_desc": "زامن تاريخ مشاهدتك وتتبع ما تشاهده", + "sign_in": "تسجيل الدخول بـ Simkl", + "sign_out": "قطع الاتصال", + "sign_out_confirm": "هل أنت متأكد من أنك تريد قطع الاتصال من Simkl؟", + "syncing_desc": "عناصرك المشاهدة تتم مزامنتها مع Simkl.", + "auth_success_title": "تم الاتصال بنجاح", + "auth_success_msg": "تم ربط حساب Simkl الخاص بك بنجاح.", + "auth_error_title": "خطأ في المصادقة", + "auth_error_msg": "فشل في إكمال المصادقة مع Simkl.", + "auth_error_generic": "حدث خطأ أثناء المصادقة.", + "sign_out_error": "فشل في قطع الاتصال من Simkl.", + "config_error_title": "خطأ في التكوين", + "config_error_msg": "معرف عميل Simkl مفقود في متغيرات البيئة.", + "conflict_title": "تعارض", + "conflict_msg": "لا يمكنك ربط Simkl بينما Trakt متصل. يرجى قطع اتصال Trakt أولاً.", + "disclaimer": "Nuvio غير مرتبط بشركة Simkl." + }, "tmdb_settings": { "title": "إعدادات TMDb", "metadata_enrichment": "إثراء البيانات التعريفية", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "متصل", "mdblist_desc": "تفعيل لإضافة التقييمات والمراجعات", + "simkl": "Simkl", + "simkl_connected": "متصل", + "simkl_desc": "تتبع ما تشاهده", "tmdb": "TMDB", "tmdb_desc": "مزود البيانات التعريفية والشعارات", "openrouter": "OpenRouter API", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ace11a3d..3501bcf9 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -522,6 +522,27 @@ "sync_success_msg": "Wiedergabefortschritt erfolgreich mit Trakt synchronisiert.", "sync_error_msg": "Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut." }, + "simkl": { + "title": "Simkl Einstellungen", + "settings_title": "Simkl Einstellungen", + "connect_title": "Mit Simkl verbinden", + "connect_desc": "Synchronisieren Sie Ihren Verlauf und verfolgen Sie, was Sie sehen", + "sign_in": "Mit Simkl anmelden", + "sign_out": "Trennen", + "sign_out_confirm": "Sind Sie sicher, dass Sie die Verbindung zu Simkl trennen möchten?", + "syncing_desc": "Ihre gesehenen Elemente werden mit Simkl synchronisiert.", + "auth_success_title": "Erfolgreich verbunden", + "auth_success_msg": "Ihr Simkl-Konto wurde erfolgreich verbunden.", + "auth_error_title": "Authentifizierungsfehler", + "auth_error_msg": "Authentifizierung mit Simkl fehlgeschlagen.", + "auth_error_generic": "Bei der Authentifizierung ist ein Fehler aufgetreten.", + "sign_out_error": "Verbindung zu Simkl konnte nicht getrennt werden.", + "config_error_title": "Konfigurationsfehler", + "config_error_msg": "Simkl Client ID fehlt in den Umgebungsvariablen.", + "conflict_title": "Konflikt", + "conflict_msg": "Sie können Simkl nicht verbinden, während Trakt verbunden ist. Bitte trennen Sie zuerst Trakt.", + "disclaimer": "Nuvio ist nicht mit Simkl verbunden." + }, "tmdb_settings": { "title": "TMDb Einstellungen", "metadata_enrichment": "Metadaten-Anreicherung", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "Verbunden", "mdblist_desc": "Aktivieren, um Bewertungen & Rezensionen hinzuzufügen", + "simkl": "Simkl", + "simkl_connected": "Verbunden", + "simkl_desc": "Verfolge, was du schaust", "tmdb": "TMDB", "tmdb_desc": "Metadaten- & Logo-Quellanbieter", "openrouter": "OpenRouter API", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f11b13a7..3a3b52a2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -522,6 +522,27 @@ "sync_success_msg": "Successfully synced your watch progress with Trakt.", "sync_error_msg": "Sync failed. Please try again." }, + "simkl": { + "title": "Simkl Settings", + "settings_title": "Simkl Settings", + "connect_title": "Connect with Simkl", + "connect_desc": "Sync your watch history and track what you're watching", + "sign_in": "Sign In with Simkl", + "sign_out": "Disconnect", + "sign_out_confirm": "Are you sure you want to disconnect from Simkl?", + "syncing_desc": "Your watched items are syncing with Simkl.", + "auth_success_title": "Successfully Connected", + "auth_success_msg": "Your Simkl account has been connected successfully.", + "auth_error_title": "Authentication Error", + "auth_error_msg": "Failed to complete authentication with Simkl.", + "auth_error_generic": "An error occurred during authentication.", + "sign_out_error": "Failed to disconnect from Simkl.", + "config_error_title": "Configuration Error", + "config_error_msg": "Simkl Client ID is missing in environment variables.", + "conflict_title": "Conflict", + "conflict_msg": "You cannot connect to Simkl while Trakt is connected. Please disconnect Trakt first.", + "disclaimer": "Nuvio is not affiliated with Simkl." + }, "tmdb_settings": { "title": "TMDb Settings", "metadata_enrichment": "Metadata Enrichment", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "Connected", "mdblist_desc": "Enable to add ratings & reviews", + "simkl": "Simkl", + "simkl_connected": "Connected", + "simkl_desc": "Track what you watch", "tmdb": "TMDB", "tmdb_desc": "Metadata & logo source provider", "openrouter": "OpenRouter API", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2efd681c..d542aa8b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -522,6 +522,27 @@ "sync_success_msg": "Sincronización del progreso con Trakt completada con éxito.", "sync_error_msg": "La sincronización falló. Por favor, inténtalo de nuevo." }, + "simkl": { + "title": "Configuración de Simkl", + "settings_title": "Configuración de Simkl", + "connect_title": "Conectar con Simkl", + "connect_desc": "Sincroniza tu historial de visualización y rastrea lo que ves", + "sign_in": "Iniciar sesión con Simkl", + "sign_out": "Desconectar", + "sign_out_confirm": "¿Estás seguro de que quieres desconectar de Simkl?", + "syncing_desc": "Tus elementos vistos se están sincronizando con Simkl.", + "auth_success_title": "Conectado exitosamente", + "auth_success_msg": "Tu cuenta de Simkl se ha conectado exitosamente.", + "auth_error_title": "Error de autenticación", + "auth_error_msg": "Error al completar la autenticación con Simkl.", + "auth_error_generic": "Ocurrió un error durante la autenticación.", + "sign_out_error": "Error al desconectar de Simkl.", + "config_error_title": "Error de configuración", + "config_error_msg": "El ID de cliente de Simkl falta en las variables de entorno.", + "conflict_title": "Conflicto", + "conflict_msg": "No puedes conectar Simkl mientras Trakt está conectado. Por favor, desconecta Trakt primero.", + "disclaimer": "Nuvio no está afiliado con Simkl." + }, "tmdb_settings": { "title": "Ajustes de TMDb", "metadata_enrichment": "Enriquecimiento de metadatos", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "Conectado", "mdblist_desc": "Activar para añadir valoraciones y reseñas", + "simkl": "Simkl", + "simkl_connected": "Conectado", + "simkl_desc": "Rastrea lo que ves", "tmdb": "TMDB", "tmdb_desc": "Proveedor de metadatos y logos", "openrouter": "API de OpenRouter", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1bcd2808..38008ebc 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -522,6 +522,27 @@ "sync_success_msg": "Votre progression a été synchronisée avec succès avec Trakt.", "sync_error_msg": "La synchronisation a échoué. Veuillez réessayer." }, + "simkl": { + "title": "Paramètres Simkl", + "settings_title": "Paramètres Simkl", + "connect_title": "Se connecter avec Simkl", + "connect_desc": "Synchronisez votre historique de visionnage et suivez ce que vous regardez", + "sign_in": "Se connecter avec Simkl", + "sign_out": "Déconnecter", + "sign_out_confirm": "Êtes-vous sûr de vouloir vous déconnecter de Simkl ?", + "syncing_desc": "Vos éléments regardés sont synchronisés avec Simkl.", + "auth_success_title": "Connecté avec succès", + "auth_success_msg": "Votre compte Simkl a été connecté avec succès.", + "auth_error_title": "Erreur d'authentification", + "auth_error_msg": "Échec de l'authentification avec Simkl.", + "auth_error_generic": "Une erreur s'est produite lors de l'authentification.", + "sign_out_error": "Échec de la déconnexion de Simkl.", + "config_error_title": "Erreur de configuration", + "config_error_msg": "L'ID client Simkl est manquant dans les variables d'environnement.", + "conflict_title": "Conflit", + "conflict_msg": "Vous ne pouvez pas connecter Simkl tant que Trakt est connecté. Veuillez d'abord déconnecter Trakt.", + "disclaimer": "Nuvio n'est pas affilié à Simkl." + }, "tmdb_settings": { "title": "Paramètres TMDb", "metadata_enrichment": "Enrichissement des métadonnées", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "Connecté", "mdblist_desc": "Activer pour ajouter les notes et avis", + "simkl": "Simkl", + "simkl_connected": "Connecté", + "simkl_desc": "Suivez ce que vous regardez", "tmdb": "TMDB", "tmdb_desc": "Fournisseur de métadonnées et de logos", "openrouter": "API OpenRouter", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6559bc0f..d1895f0b 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -522,6 +522,27 @@ "sync_success_msg": "Progressi di visione sincronizzati con successo con Trakt.", "sync_error_msg": "Sincronizzazione fallita. Riprova." }, + "simkl": { + "title": "Impostazioni Simkl", + "settings_title": "Impostazioni Simkl", + "connect_title": "Connetti con Simkl", + "connect_desc": "Sincronizza la tua cronologia di visione e traccia ciò che guardi", + "sign_in": "Accedi con Simkl", + "sign_out": "Disconnetti", + "sign_out_confirm": "Sei sicuro di voler disconnettere da Simkl?", + "syncing_desc": "I tuoi elementi guardati sono in sincronizzazione con Simkl.", + "auth_success_title": "Connesso con successo", + "auth_success_msg": "Il tuo account Simkl è stato connesso con successo.", + "auth_error_title": "Errore di autenticazione", + "auth_error_msg": "Impossibile completare l'autenticazione con Simkl.", + "auth_error_generic": "Si è verificato un errore durante l'autenticazione.", + "sign_out_error": "Impossibile disconnettere da Simkl.", + "config_error_title": "Errore di configurazione", + "config_error_msg": "L'ID client Simkl manca nelle variabili d'ambiente.", + "conflict_title": "Conflitto", + "conflict_msg": "Non puoi connettere Simkl mentre Trakt è connesso. Disconnetti prima Trakt.", + "disclaimer": "Nuvio non è affiliato con Simkl." + }, "tmdb_settings": { "title": "Impostazioni TMDb", "metadata_enrichment": "Arricchimento metadati", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "Connesso", "mdblist_desc": "Abilita per aggiungere voti e recensioni", + "simkl": "Simkl", + "simkl_connected": "Connesso", + "simkl_desc": "Traccia ciò che guardi", "tmdb": "TMDB", "tmdb_desc": "Sorgente metadati e loghi", "openrouter": "API OpenRouter", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 05f9823b..277ba9ef 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -522,6 +522,27 @@ "sync_success_msg": "Progresso sincronizado com sucesso com o Trakt.", "sync_error_msg": "Falha na sincronização. Tente novamente." }, + "simkl": { + "title": "Configurações do Simkl", + "settings_title": "Configurações do Simkl", + "connect_title": "Conectar com Simkl", + "connect_desc": "Sincronize seu histórico de visualização e rastreie o que você assiste", + "sign_in": "Entrar com Simkl", + "sign_out": "Desconectar", + "sign_out_confirm": "Tem certeza de que deseja desconectar do Simkl?", + "syncing_desc": "Seus itens assistidos estão sendo sincronizados com o Simkl.", + "auth_success_title": "Conectado com sucesso", + "auth_success_msg": "Sua conta Simkl foi conectada com sucesso.", + "auth_error_title": "Erro de autenticação", + "auth_error_msg": "Falha ao completar a autenticação com o Simkl.", + "auth_error_generic": "Ocorreu um erro durante a autenticação.", + "sign_out_error": "Falha ao desconectar do Simkl.", + "config_error_title": "Erro de configuração", + "config_error_msg": "O ID do cliente Simkl está faltando nas variáveis de ambiente.", + "conflict_title": "Conflito", + "conflict_msg": "Você não pode conectar o Simkl enquanto o Trakt está conectado. Desconecte o Trakt primeiro.", + "disclaimer": "Nuvio não é afiliado ao Simkl." + }, "tmdb_settings": { "title": "Configurações do TMDb", "metadata_enrichment": "Enriquecimento de Metadados", @@ -689,6 +710,9 @@ "mdblist": "MDBList", "mdblist_connected": "Conectado", "mdblist_desc": "Habilitar para adicionar avaliações e resenhas", + "simkl": "Simkl", + "simkl_connected": "Conectado", + "simkl_desc": "Acompanhe o que você assiste", "tmdb": "TMDB", "tmdb_desc": "Provedor de metadados e logos", "openrouter": "OpenRouter API", diff --git a/src/i18n/locales/pt-PT.json b/src/i18n/locales/pt-PT.json index 424fd409..8345eed9 100644 --- a/src/i18n/locales/pt-PT.json +++ b/src/i18n/locales/pt-PT.json @@ -522,6 +522,27 @@ "sync_success_msg": "Progresso sincronizado com sucesso com o Trakt.", "sync_error_msg": "Falha na sincronização. Tenta novamente." }, + "simkl": { + "title": "Configurações do Simkl", + "settings_title": "Configurações do Simkl", + "connect_title": "Ligar ao Simkl", + "connect_desc": "Sincroniza o teu histórico de visualização e rastreia o que vês", + "sign_in": "Entrar com Simkl", + "sign_out": "Desligar", + "sign_out_confirm": "Tens a certeza de que queres desligar do Simkl?", + "syncing_desc": "Os teus itens vistos estão a ser sincronizados com o Simkl.", + "auth_success_title": "Ligado com sucesso", + "auth_success_msg": "A tua conta Simkl foi ligada com sucesso.", + "auth_error_title": "Erro de autenticação", + "auth_error_msg": "Falha ao completar a autenticação com o Simkl.", + "auth_error_generic": "Ocorreu um erro durante a autenticação.", + "sign_out_error": "Falha ao desligar do Simkl.", + "config_error_title": "Erro de configuração", + "config_error_msg": "O ID do cliente Simkl está em falta nas variáveis de ambiente.", + "conflict_title": "Conflito", + "conflict_msg": "Não podes ligar o Simkl enquanto o Trakt está ligado. Desliga primeiro o Trakt.", + "disclaimer": "Nuvio não é afiliado ao Simkl." + }, "tmdb_settings": { "title": "Configurações do TMDb", "metadata_enrichment": "Enriquecimento de Metadados", @@ -689,6 +710,9 @@ "mdblist": "MDBList", "mdblist_connected": "Conectado", "mdblist_desc": "Ativar para adicionar avaliações e críticas", + "simkl": "Simkl", + "simkl_connected": "Conectado", + "simkl_desc": "Acompanhe o que vê", "tmdb": "TMDB", "tmdb_desc": "Provedor de metadados e logos", "openrouter": "OpenRouter API", 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 @@ -372,12 +375,23 @@ const SettingsScreen: React.FC = () => { return ( {isItemVisible('trakt') && ( - } + } 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 { t } = useTranslation(); + + const { + isAuthenticated, + isLoading, + checkAuthStatus, + refreshAuthStatus, + userSettings, + userStats + } = 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(t('common.success'), t('simkl.auth_success_msg')); + } else { + openAlert(t('common.error'), t('simkl.auth_error_msg')); + } + }) + .catch(err => { + logger.error('[SimklSettingsScreen] Token exchange error:', err); + openAlert(t('common.error'), t('simkl.auth_error_generic')); + }) + .finally(() => setIsExchangingCode(false)); + } else if (response.type === 'error') { + openAlert(t('simkl.auth_error_title'), t('simkl.auth_error_generic') + ' ' + (response.error?.message || t('common.unknown'))); + } + } + }, [response, refreshAuthStatus]); + + const handleSignIn = () => { + if (!SIMKL_CLIENT_ID) { + openAlert(t('simkl.config_error_title'), t('simkl.config_error_msg')); + return; + } + + if (isTraktAuthenticated) { + openAlert(t('simkl.conflict_title'), t('simkl.conflict_msg')); + return; + } + + promptAsync(); + }; + + const handleSignOut = async () => { + await simklService.logout(); + refreshAuthStatus(); + openAlert(t('common.success'), t('simkl.sign_out_confirm')); + }; + + return ( + + + + navigation.goBack()} + style={styles.backButton} + > + + + Settings + + + + + + {t('simkl.settings_title')} (Alpha) + + + + + {isLoading ? ( + + + + ) : isAuthenticated ? ( + + + {userSettings?.user?.avatar ? ( + + ) : ( + + + + )} + + {userSettings?.user && ( + + {userSettings.user.name} + + )} + {userSettings?.account?.type && ( + + {userSettings.account.type} Account + + )} + + + + {t('simkl.syncing_desc')} + + + {userStats && ( + + + + {userStats.movies?.completed?.count || 0} + + + Movies + + + + + {(userStats.tv?.watching?.count || 0) + (userStats.tv?.completed?.count || 0)} + + + TV Shows + + + + + {userStats.anime?.completed?.count || 0} + + + Anime + + + + + {Math.round(((userStats.total_mins || 0) + (userStats.movies?.total_mins || 0) + (userStats.tv?.total_mins || 0) + (userStats.anime?.total_mins || 0)) / 60)}h + + + Watched + + + + )} + + + {t('simkl.sign_out')} + + + ) : ( + + + {t('simkl.connect_title')} + + + {t('simkl.connect_desc')} + + + {isExchangingCode ? ( + + ) : ( + {t('simkl.sign_in')} + )} + + + )} + + + + + + + + {t('simkl.disclaimer')} + + + + 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: 'stretch', + paddingVertical: 8, + }, + profileHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + }, + avatar: { + width: 44, + height: 44, + borderRadius: 22, + marginRight: 12, + backgroundColor: '#00000010', + }, + avatarPlaceholder: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + profileText: { + flex: 1, + }, + statusTitle: { + fontSize: 18, + fontWeight: '700', + marginBottom: 2, + }, + accountType: { + fontSize: 13, + fontWeight: '500', + marginBottom: 8, + }, + statusDesc: { + fontSize: 14, + marginBottom: 8, + }, + statsGrid: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 16, + marginVertical: 12, + borderTopWidth: 1, + borderBottomWidth: 1, + }, + statItem: { + alignItems: 'center', + }, + statValue: { + fontSize: 17, + fontWeight: '700', + marginBottom: 4, + }, + statLabel: { + fontSize: 12, + fontWeight: '500', + }, + button: { + width: '100%', + height: 48, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + color: 'white', + }, + disclaimer: { + fontSize: 12, + textAlign: 'center', + marginTop: 8, + marginBottom: 8, + }, + logoSection: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 20, + marginTop: 16, + marginBottom: 0, + }, + logo: { + width: 150, + height: 30, + }, + logoContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 28, + marginBottom: 24, + borderRadius: 12, + elevation: 2, + shadowColor: '#000', + shadowOpacity: 0.1, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + }, +}); + +export default SimklSettingsScreen; diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index 68c3c333..81b57bb6 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -16,13 +16,14 @@ import { useNavigation } from '@react-navigation/native'; import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import FastImage from '@d11/react-native-fast-image'; -import { traktService, TraktUser } from '../services/traktService'; +import { traktService, TraktUser, TraktUserStats } from '../services/traktService'; import { useSettings } from '../hooks/useSettings'; import { logger } from '../utils/logger'; 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'; @@ -54,6 +55,7 @@ const TraktSettingsScreen: React.FC = () => { const [isLoading, setIsLoading] = useState(true); const [isAuthenticated, setIsAuthenticated] = useState(false); const [userProfile, setUserProfile] = useState(null); + const [userStats, setUserStats] = useState(null); const { currentTheme } = useTheme(); const { @@ -67,6 +69,7 @@ const TraktSettingsScreen: React.FC = () => { isLoading: traktLoading, refreshAuthStatus } = useTraktIntegration(); + const { isAuthenticated: isSimklAuthenticated } = useSimklIntegration(); const [showSyncFrequencyModal, setShowSyncFrequencyModal] = useState(false); const [showThresholdModal, setShowThresholdModal] = useState(false); @@ -107,8 +110,16 @@ const TraktSettingsScreen: React.FC = () => { if (authenticated) { const profile = await traktService.getUserProfile(); setUserProfile(profile); + try { + const stats = await traktService.getUserStats(); + setUserStats(stats); + } catch (statsError) { + logger.warn('[TraktSettingsScreen] Failed to load stats:', statsError); + setUserStats(null); + } } else { setUserProfile(null); + setUserStats(null); } } catch (error) { logger.error('[TraktSettingsScreen] Error checking auth status:', error); @@ -184,6 +195,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 }; @@ -347,6 +362,42 @@ const TraktSettingsScreen: React.FC = () => { ]}> {t('trakt.joined', { date: new Date(userProfile.joined_at).toLocaleDateString() })} + {userStats && ( + + + + {userStats.movies?.watched ?? 0} + + + Movies + + + + + {userStats.shows?.watched ?? 0} + + + Shows + + + + + {userStats.episodes?.watched ?? 0} + + + Episodes + + + + + {Math.round(((userStats.minutes ?? 0) + (userStats.movies?.minutes ?? 0) + (userStats.shows?.minutes ?? 0) + (userStats.episodes?.minutes ?? 0)) / 60)}h + + + Watched + + + + )} { return 0; }; - const allStreams: Array<{ stream: Stream; quality: number; providerPriority: number }> = []; + const allStreams: Array<{ stream: Stream; quality: number; providerPriority: number; originalIndex: number }> = []; Object.entries(streamsData).forEach(([addonId, { streams }]) => { const qualityFiltered = filterByQuality(streams); const filteredStreams = filterByLanguage(qualityFiltered); - filteredStreams.forEach(stream => { + filteredStreams.forEach((stream, index) => { const quality = getQualityNumeric(stream.name || stream.title); const providerPriority = getProviderPriority(addonId); - allStreams.push({ stream, quality, providerPriority }); + allStreams.push({ stream, quality, providerPriority, originalIndex: index }); }); }); if (allStreams.length === 0) return null; + // Sort primarily by provider priority, then respect the addon's internal order (originalIndex) + // This ensures if an addon lists 1080p before 4K, we pick 1080p allStreams.sort((a, b) => { - if (a.quality !== b.quality) return b.quality - a.quality; if (a.providerPriority !== b.providerPriority) return b.providerPriority - a.providerPriority; - return 0; + return a.originalIndex - b.originalIndex; }); logger.log( @@ -355,11 +355,17 @@ export const useStreamsScreen = () => { // Navigate to player const navigateToPlayer = useCallback( async (stream: Stream, options?: { headers?: Record }) => { - const finalHeaders = filterHeadersForVidrock(options?.headers || (stream.headers as any)); + const optionHeaders = options?.headers; + const streamHeaders = (stream.headers as any) as Record | undefined; + const proxyHeaders = ((stream as any)?.behaviorHints?.proxyHeaders?.request || undefined) as + | Record + | undefined; + const streamProvider = stream.addonId || (stream as any).addonName || stream.name; + const finalHeaders = optionHeaders || streamHeaders || proxyHeaders; const streamsToPass = selectedEpisode ? episodeStreams : groupedStreams; const streamName = stream.name || stream.title || 'Unnamed Stream'; - const streamProvider = stream.addonId || stream.addonName || stream.name; + const resolvedStreamProvider = streamProvider; // Save stream to cache try { @@ -392,6 +398,22 @@ export const useStreamsScreen = () => { } } catch { } + if (__DEV__) { + const finalHeaderKeys = Object.keys(finalHeaders || {}); + + logger.log('[StreamsScreen][navigateToPlayer] stream selection', { + url: typeof stream.url === 'string' ? stream.url.slice(0, 240) : stream.url, + addonId: stream.addonId, + addonName: (stream as any).addonName, + name: stream.name, + title: stream.title, + inferredVideoType: videoType, + optionHeadersKeys: Object.keys(optionHeaders || {}), + streamHeadersKeys: Object.keys(streamHeaders || {}), + finalHeadersKeys: finalHeaderKeys, + }); + } + const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; navigation.navigate(playerRoute as any, { @@ -402,7 +424,7 @@ export const useStreamsScreen = () => { episode: (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined, quality: (stream.title?.match(/(\d+)p/) || [])[1] || undefined, year: metadata?.year, - streamProvider, + streamProvider: resolvedStreamProvider, streamName, headers: finalHeaders, id, @@ -423,6 +445,24 @@ export const useStreamsScreen = () => { try { if (!stream.url) return; + if (__DEV__) { + const streamHeaders = (stream.headers as any) as Record | undefined; + const proxyHeaders = ((stream as any)?.behaviorHints?.proxyHeaders?.request || undefined) as + | Record + | undefined; + + logger.log('[StreamsScreen][handleStreamPress] pressed stream', { + url: typeof stream.url === 'string' ? stream.url.slice(0, 240) : stream.url, + addonId: stream.addonId, + addonName: (stream as any).addonName, + name: stream.name, + title: stream.title, + streamHeadersKeys: Object.keys(streamHeaders || {}), + proxyHeadersKeys: Object.keys(proxyHeaders || {}), + inferredVideoType: inferVideoTypeFromUrl(stream.url), + }); + } + // Block magnet links if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) { openAlert('Not supported', 'Torrent streaming is not supported yet.'); diff --git a/src/services/simklService.ts b/src/services/simklService.ts new file mode 100644 index 00000000..7f6691dd --- /dev/null +++ b/src/services/simklService.ts @@ -0,0 +1,608 @@ +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; + // Simkl docs show `episode` in playback responses, but some APIs return `number` + episode?: number; + number?: number; + title: string; + tvdb_season?: number; + tvdb_number?: number; + }; +} + +export interface SimklUserSettings { + user: { + name: string; + joined_at: string; + gender?: string; + avatar: string; + bio?: string; + loc?: string; + age?: string; + }; + account: { + id: number; + timezone?: string; + type?: string; + }; + connections?: Record; +} + +export interface SimklStats { + total_mins: number; + movies?: { + total_mins: number; + completed?: { count: number }; + }; + tv?: { + total_mins: number; + watching?: { count: number }; + completed?: { count: number }; + }; + anime?: { + total_mins: number; + watching?: { count: number }; + completed?: { count: 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 { + // Docs: GET /sync/playback/{type} with {type} values `movies` or `episodes`. + // Some docs also mention appending /movie or /episode; we try both variants for safety. + const tryEndpoints = async (endpoints: string[]): Promise => { + for (const endpoint of endpoints) { + try { + const res = await this.apiRequest(endpoint); + if (Array.isArray(res)) { + logger.log(`[SimklService] getPlaybackStatus: ${endpoint} -> ${res.length} items`); + return res; + } + } catch (e) { + logger.warn(`[SimklService] getPlaybackStatus: ${endpoint} failed`, e); + } + } + return []; + }; + + const movies = await tryEndpoints([ + '/sync/playback/movies', + '/sync/playback/movie', + '/sync/playback?type=movies' + ]); + + const episodes = await tryEndpoints([ + '/sync/playback/episodes', + '/sync/playback/episode', + '/sync/playback?type=episodes' + ]); + + const combined = [...episodes, ...movies] + .filter(Boolean) + .sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime()); + + logger.log(`[SimklService] getPlaybackStatus: combined ${combined.length} items (episodes=${episodes.length}, movies=${movies.length})`); + return combined; + } + + /** + * 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); + } + + /** + * Get user settings/profile + */ + public async getUserSettings(): Promise { + try { + const response = await this.apiRequest('/users/settings', 'POST'); + logger.log('[SimklService] getUserSettings:', JSON.stringify(response)); + return response; + } catch (error) { + logger.error('[SimklService] Failed to get user settings:', error); + return null; + } + } + + /** + * Get user stats + */ + public async getUserStats(): Promise { + try { + if (!await this.isAuthenticated()) { + return null; + } + + // Need account ID from settings first + const settings = await this.getUserSettings(); + if (!settings?.account?.id) { + logger.warn('[SimklService] Cannot get user stats: no account ID'); + return null; + } + + const response = await this.apiRequest(`/users/${settings.account.id}/stats`, 'POST'); + logger.log('[SimklService] getUserStats:', JSON.stringify(response)); + return response; + } catch (error) { + logger.error('[SimklService] Failed to get user stats:', error); + return null; + } + }} \ No newline at end of file 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/traktService.ts b/src/services/traktService.ts index 80b0b6c5..bb5de6f9 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -27,6 +27,22 @@ export interface TraktUser { avatar?: string; } +export interface TraktUserStats { + movies?: { + watched?: number; + minutes?: number; + }; + shows?: { + watched?: number; + minutes?: number; + }; + episodes?: { + watched?: number; + minutes?: number; + }; + minutes?: number; // total minutes watched +} + export interface TraktWatchedItem { movie?: { title: string; @@ -1117,6 +1133,13 @@ export class TraktService { return this.apiRequest('/users/me?extended=full'); } + /** + * Get the user's watch stats + */ + public async getUserStats(): Promise { + return this.apiRequest('/users/me/stats'); + } + /** * Get the user's watched movies */ 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 */