diff --git a/.env.example b/.env.example index 68c1966..e4b9297 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ -EXPO_NO_TELEMETRY=true \ No newline at end of file +EXPO_NO_TELEMETRY=true +EXPO_PUBLIC_SIMKL_CLIENT_ID=your_simkl_client_id_here \ No newline at end of file diff --git a/docs/adr/adr_1_simkl_integration.md b/docs/adr/adr_1_simkl_integration.md new file mode 100644 index 0000000..2f9c9f7 --- /dev/null +++ b/docs/adr/adr_1_simkl_integration.md @@ -0,0 +1,1808 @@ +--- +id: adr-XXXX +title: External Watch History & Scrobbling Integration (Simkl, Trakt) +status: Proposed +date: 2025-12-27 +tags: + - feature + - scrobbling + - external-api + - watch-history + - simkl + - trakt + - cross-device-sync +--- + +# ADR-XXXX: External Watch History & Scrobbling Integration (Simkl, Trakt) + +## Context + +DodoStream currently tracks watch history locally per profile. However, **DodoStream does not offer user accounts or cloud sync**. Users who install DodoStream on multiple devices have no way to sync their watch progress. + +By integrating with Simkl and/or Trakt, we enable cross-device sync without building our own account system. + +### Key User Story + +> "I set up Simkl on my Android TV once. Later, I watch a movie on my phone. When I open DodoStream on my TV again, it automatically knows I've already watched that movie - no manual sync needed." + +--- + +## Decision + +### Design Principles + +1. **Functional over classes** - All modules use pure functions, not classes +2. **Arrow functions for components** - Use `FC` types and arrow functions +3. **Environment variables** - API keys via `EXPO_PUBLIC_*` env vars +4. **PIN auth only** - No OAuth (requires `client_secret` we can't include) +5. **Reactive store subscriptions** - Scrobbling via store middleware +6. **Scrobble state in provider stores** - Each integration manages its own state +7. **Reusable utilities** - Shared video ID parsing/formatting utils + +--- + +## Environment Configuration + +```typescript +// . env (or .env.local) +EXPO_PUBLIC_SIMKL_CLIENT_ID = your - simkl - client - id - here; +// EXPO_PUBLIC_TRAKT_CLIENT_ID=your-trakt-client-id-here # Future + +// NOTE: client_id is safe to publish (public identifier) +// NEVER include client_secret in client-side code +``` + +```typescript +// src/utils/env.ts + +/** + * Get Simkl client ID from environment + * @throws if not configured + */ +export const getSimklClientId = (): string => { + const clientId = process.env.EXPO_PUBLIC_SIMKL_CLIENT_ID; + if (!clientId) { + throw new Error('EXPO_PUBLIC_SIMKL_CLIENT_ID not configured. Add it to your .env file.'); + } + return clientId; +}; + +/** + * Check if Simkl is configured + */ +export const isSimklConfigured = (): boolean => { + return Boolean(process.env.EXPO_PUBLIC_SIMKL_CLIENT_ID); +}; +``` + +--- + +## Video ID Utilities + +Reusable utils for splitting/joining video IDs with consistent separators: + +```typescript +// src/utils/video-id. ts + +/** + * Separator for show ID + video ID composite key + * Example: "tt1234567:: tt1234567:1:5" + */ +const COMPOSITE_KEY_SEPARATOR = '::'; + +/** + * Separator within video ID for season: episode + * Example: "tt1234567:1:5" (imdb: season:episode) + */ +const VIDEO_ID_SEPARATOR = ':'; + +export interface ParsedVideoId { + showId: string; + season: number; + episode: number; +} + +export interface CompositeKey { + mediaId: string; + videoId: string | undefined; +} + +/** + * Create a video ID from season and episode + * Format: "{showId}:{season}:{episode}" + */ +export const createVideoId = ( + showId: string, + season: number, + episode: number +): string => { + return `${showId}${VIDEO_ID_SEPARATOR}${season}${VIDEO_ID_SEPARATOR}${episode}`; +}; + +/** + * Parse a video ID into components + * Returns undefined if format is invalid + */ +export const parseVideoId = (videoId: string): ParsedVideoId | undefined => { + const parts = videoId.split(VIDEO_ID_SEPARATOR); + if (parts.length < 3) return undefined; + + const showId = parts[0]; + const season = parseInt(parts[1], 10); + const episode = parseInt(parts[2], 10); + + if (isNaN(season) || isNaN(episode)) return undefined; + + return { showId, season, episode }; +}; + +/** + * Create a composite key for watch history storage + * Format: "{mediaId}: :{videoId}" or just "{mediaId}" for movies + */ +export const createCompositeKey = ( + mediaId: string, + videoId?: string +): string => { + if (! videoId) return mediaId; + return `${mediaId}${COMPOSITE_KEY_SEPARATOR}${videoId}`; +}; + +/** + * Parse a composite key back into components + */ +export const parseCompositeKey = (key: string): CompositeKey => { + const separatorIndex = key. indexOf(COMPOSITE_KEY_SEPARATOR); + if (separatorIndex === -1) { + return { mediaId: key, videoId: undefined }; + } + return { + mediaId: key.slice(0, separatorIndex), + videoId: key.slice(separatorIndex + COMPOSITE_KEY_SEPARATOR.length), + }; +}; + +/** + * Check if a media ID is an IMDB ID + */ +export const isImdbId = (id: string): boolean => { + return id.startsWith('tt') && /^tt\d+$/.test(id); +}; + +/** + * Extract IMDB ID from various formats + * Returns undefined if not an IMDB ID + */ +export const extractImdbId = (id: string): string | undefined => { + if (isImdbId(id)) return id; + + // Try to extract from URL format + const match = id.match(/tt\d+/); + return match? .[0]; +}; +``` + +--- + +## Media ID Resolution + +When the ID is not an IMDB ID, we can use Simkl's lookup endpoints: + +```typescript +// src/api/simkl/lookup.ts + +import { getSimklClientId } from '@/utils/env'; +import { isImdbId } from '@/utils/video-id'; + +const SIMKL_API_URL = 'https://api.simkl.com'; + +export interface SimklIds { + simkl?: number; + imdb?: string; + tmdb?: number; + tvdb?: number; + mal?: number; +} + +export interface LookupResult { + ids: SimklIds; + title?: string; + year?: number; +} + +/** + * Lookup a media item on Simkl by various IDs + * + * Simkl supports lookup by: imdb, tmdb, tvdb, mal, anidb, hulu, netflix, etc. + * If we only have a non-standard ID, we try title+year search as fallback. + */ +export const lookupMedia = async ( + mediaId: string, + metadata?: { + title?: string; + year?: number; + type?: 'movie' | 'show' | 'anime'; + tmdbId?: number; + tvdbId?: number; + } +): Promise => { + // Priority 1: IMDB ID - direct lookup + if (isImdbId(mediaId)) { + return { ids: { imdb: mediaId } }; + } + + // Priority 2: TMDB/TVDB ID if provided in metadata + if (metadata?.tmdbId) { + const result = await lookupByExternalId('tmdb', metadata.tmdbId, metadata.type); + if (result) return result; + } + + if (metadata?.tvdbId) { + const result = await lookupByExternalId('tvdb', metadata.tvdbId, metadata.type); + if (result) return result; + } + + // Priority 3: Title + year search + if (metadata?.title) { + const result = await searchByTitle(metadata.title, metadata.year, metadata.type); + if (result) return result; + } + + // No match found - cannot track this item + return null; +}; + +const lookupByExternalId = async ( + idType: 'tmdb' | 'tvdb' | 'mal', + idValue: number, + type?: 'movie' | 'show' | 'anime' +): Promise => { + try { + const params = new URLSearchParams({ + [idType]: String(idValue), + client_id: getSimklClientId(), + }); + + if (type) { + params.set('type', type === 'show' ? 'tv' : type); + } + + const response = await fetch(`${SIMKL_API_URL}/search/id? ${params}`); + + if (!response.ok) return null; + + const data = await response.json(); + if (!data || data.length === 0) return null; + + return { + ids: data[0].ids, + title: data[0].title, + year: data[0].year, + }; + } catch { + return null; + } +}; + +const searchByTitle = async ( + title: string, + year?: number, + type?: 'movie' | 'show' | 'anime' +): Promise => { + try { + const endpoint = type === 'movie' ? '/search/movie' : '/search/tv'; + const params = new URLSearchParams({ + q: title, + client_id: getSimklClientId(), + }); + + if (year) { + params.set('year', String(year)); + } + + const response = await fetch(`${SIMKL_API_URL}${endpoint}?${params}`); + + if (!response.ok) return null; + + const data = await response.json(); + if (!data || data.length === 0) return null; + + return { + ids: data[0].ids, + title: data[0].title, + year: data[0].year, + }; + } catch { + return null; + } +}; + +/** + * Check if we can track this media item + * Returns true if we have enough info to identify it on Simkl + */ +export const canTrackMedia = ( + mediaId: string, + metadata?: { title?: string; tmdbId?: number; tvdbId?: number } +): boolean => { + // IMDB IDs always work + if (isImdbId(mediaId)) return true; + + // TMDB/TVDB IDs work + if (metadata?.tmdbId || metadata?.tvdbId) return true; + + // Title search can work but is less reliable + if (metadata?.title) return true; + + // No way to identify this content + return false; +}; +``` + +--- + +## Scrobble Thresholds + +Reuse existing playback constants: + +```typescript +// src/constants/tracking.ts + +import { PLAYBACK_CONTINUE_WATCHING_MIN_RATIO, PLAYBACK_FINISHED_RATIO } from './playback'; + +/** + * Scrobble thresholds - reuse existing playback ratios + */ +export const SCROBBLE_THRESHOLDS = { + /** Start scrobbling ("now watching") - 5% */ + START: PLAYBACK_CONTINUE_WATCHING_MIN_RATIO, + + /** Mark as watched/finished - 90% */ + FINISH: PLAYBACK_FINISHED_RATIO, +} as const; + +/** Minimum time between sync attempts */ +export const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes + +/** Debounce time for scrobble updates */ +export const SCROBBLE_DEBOUNCE_MS = 5000; // 5 seconds +``` + +--- + +## Simkl Store with Scrobble State + +```typescript +// src/store/simkl. store.ts + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist, subscribeWithSelector } from 'zustand/middleware'; + +interface ScrobbleSession { + mediaKey: string; + startedAt: number; + hasStarted: boolean; + hasFinished: boolean; + lastProgress: number; +} + +interface SimklUser { + id: number; + name: string; + avatar?: string; +} + +interface SimklState { + // Connection + isConnected: boolean; + accessToken: string | null; + user: SimklUser | null; + + // Settings + scrobblingEnabled: boolean; + autoSyncEnabled: boolean; + + // Sync timestamps + lastPullAt: number | null; + lastPushAt: number | null; + + // Scrobble sessions (per media item) + scrobbleSessions: Record; + + // Sync stats + syncStats: { + itemsPulled: number; + itemsPushed: number; + lastError?: string; + } | null; + + // Offline queue + offlineQueue: Array<{ + type: 'scrobble-start' | 'scrobble-finish' | 'history-add'; + payload: unknown; + createdAt: number; + }>; +} + +interface SimklActions { + connect: (accessToken: string, user: SimklUser) => void; + disconnect: () => void; + + setScrobblingEnabled: (enabled: boolean) => void; + setAutoSyncEnabled: (enabled: boolean) => void; + + setLastPullAt: (timestamp: number) => void; + setLastPushAt: (timestamp: number) => void; + setSyncStats: (stats: SimklState['syncStats']) => void; + + // Scrobble session management + getOrCreateSession: (mediaKey: string) => ScrobbleSession; + updateSession: (mediaKey: string, updates: Partial) => void; + clearSession: (mediaKey: string) => void; + clearExpiredSessions: () => void; + + // Offline queue + addToOfflineQueue: (item: SimklState['offlineQueue'][0]) => void; + clearOfflineQueue: () => void; +} + +const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours + +export const useSimklStore = create()( + subscribeWithSelector( + persist( + (set, get) => ({ + // Initial state + isConnected: false, + accessToken: null, + user: null, + scrobblingEnabled: true, + autoSyncEnabled: true, + lastPullAt: null, + lastPushAt: null, + scrobbleSessions: {}, + syncStats: null, + offlineQueue: [], + + // Actions + connect: (accessToken, user) => + set({ + isConnected: true, + accessToken, + user, + }), + + disconnect: () => + set({ + isConnected: false, + accessToken: null, + user: null, + lastPullAt: null, + lastPushAt: null, + scrobbleSessions: {}, + syncStats: null, + offlineQueue: [], + }), + + setScrobblingEnabled: (enabled) => set({ scrobblingEnabled: enabled }), + setAutoSyncEnabled: (enabled) => set({ autoSyncEnabled: enabled }), + + setLastPullAt: (timestamp) => set({ lastPullAt: timestamp }), + setLastPushAt: (timestamp) => set({ lastPushAt: timestamp }), + setSyncStats: (stats) => set({ syncStats: stats }), + + getOrCreateSession: (mediaKey) => { + const sessions = get().scrobbleSessions; + if (sessions[mediaKey]) { + return sessions[mediaKey]; + } + + const newSession: ScrobbleSession = { + mediaKey, + startedAt: Date.now(), + hasStarted: false, + hasFinished: false, + lastProgress: 0, + }; + + set({ + scrobbleSessions: { + ...sessions, + [mediaKey]: newSession, + }, + }); + + return newSession; + }, + + updateSession: (mediaKey, updates) => { + const sessions = get().scrobbleSessions; + if (!sessions[mediaKey]) return; + + set({ + scrobbleSessions: { + ...sessions, + [mediaKey]: { ...sessions[mediaKey], ...updates }, + }, + }); + }, + + clearSession: (mediaKey) => { + const sessions = { ...get().scrobbleSessions }; + delete sessions[mediaKey]; + set({ scrobbleSessions: sessions }); + }, + + clearExpiredSessions: () => { + const now = Date.now(); + const sessions = get().scrobbleSessions; + const validSessions: Record = {}; + + for (const [key, session] of Object.entries(sessions)) { + if (now - session.startedAt < SESSION_EXPIRY_MS) { + validSessions[key] = session; + } + } + + set({ scrobbleSessions: validSessions }); + }, + + addToOfflineQueue: (item) => + set((state) => ({ + offlineQueue: [...state.offlineQueue, item], + })), + + clearOfflineQueue: () => set({ offlineQueue: [] }), + }), + { + name: 'simkl-store', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + isConnected: state.isConnected, + accessToken: state.accessToken, + user: state.user, + scrobblingEnabled: state.scrobblingEnabled, + autoSyncEnabled: state.autoSyncEnabled, + lastPullAt: state.lastPullAt, + lastPushAt: state.lastPushAt, + scrobbleSessions: state.scrobbleSessions, + syncStats: state.syncStats, + offlineQueue: state.offlineQueue, + }), + } + ) + ) +); +``` + +--- + +## Tracking Store (Global) + +```typescript +// src/store/tracking.store.ts + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +type SyncState = 'idle' | 'syncing' | 'error' | 'offline'; + +interface SyncStats { + itemsPulled: number; + itemsPushed: number; + lastError?: string; +} + +interface TrackingState { + autoSyncEnabled: boolean; + syncState: SyncState; + lastSyncAt: number | null; + syncStats: SyncStats | null; +} + +interface TrackingActions { + setAutoSyncEnabled: (enabled: boolean) => void; + setSyncState: (state: SyncState) => void; + setLastSyncAt: (timestamp: number) => void; + setSyncStats: (stats: SyncStats) => void; +} + +export const useTrackingStore = create()( + persist( + (set) => ({ + autoSyncEnabled: true, + syncState: 'idle', + lastSyncAt: null, + syncStats: null, + + setAutoSyncEnabled: (enabled) => set({ autoSyncEnabled: enabled }), + setSyncState: (syncState) => set({ syncState }), + setLastSyncAt: (timestamp) => set({ lastSyncAt: timestamp }), + setSyncStats: (stats) => set({ syncStats: stats }), + }), + { + name: 'tracking-settings', + storage: createJSONStorage(() => AsyncStorage), + partialize: (state) => ({ + autoSyncEnabled: state.autoSyncEnabled, + lastSyncAt: state.lastSyncAt, + syncStats: state.syncStats, + }), + } + ) +); +``` + +--- + +## PIN Authentication + +```typescript +// src/api/simkl/auth.ts + +import { getSimklClientId } from '@/utils/env'; +import { useSimklStore } from '@/store/simkl.store'; +import { getSimklUser } from './client'; + +const SIMKL_API_URL = 'https://api.simkl.com'; + +interface PinCodeResponse { + result: 'OK'; + device_code: string; + user_code: string; + verification_url: string; + expires_in: number; + interval: number; +} + +interface PinStatusResponse { + result: 'OK' | 'KO'; + message?: 'Authorization pending' | 'Slow down'; + access_token?: string; +} + +/** + * Request a PIN code to display to the user + */ +export const requestPinCode = async (): Promise => { + const response = await fetch(`${SIMKL_API_URL}/oauth/pin? client_id=${getSimklClientId()}`); + + if (!response.ok) { + throw new Error('Failed to get PIN code'); + } + + return response.json(); +}; + +/** + * Poll for user authorization + * Returns access_token when user enters the code on simkl.com/pin/ + */ +export const pollPinStatus = async ( + userCode: string, + options: { + interval: number; + expiresIn: number; + onPending?: () => void; + signal?: AbortSignal; + } +): Promise => { + const { interval, expiresIn, onPending, signal } = options; + const startTime = Date.now(); + const expiresAt = startTime + expiresIn * 1000; + + while (Date.now() < expiresAt) { + if (signal?.aborted) { + throw new Error('PIN auth cancelled'); + } + + await new Promise((resolve) => setTimeout(resolve, interval * 1000)); + + const response = await fetch( + `${SIMKL_API_URL}/oauth/pin/${userCode}? client_id=${getSimklClientId()}` + ); + + const data: PinStatusResponse = await response.json(); + + if (data.result === 'OK' && data.access_token) { + return data.access_token; + } + + if (data.message === 'Slow down') { + await new Promise((resolve) => setTimeout(resolve, interval * 1000)); + } + + onPending?.(); + } + + throw new Error('PIN code expired'); +}; + +/** + * Complete PIN auth flow + */ +export const completePinAuth = async (accessToken: string): Promise => { + try { + const user = await getSimklUser(accessToken); + useSimklStore.getState().connect(accessToken, user); + return true; + } catch { + return false; + } +}; +``` + +--- + +## Scrobbling via Store Middleware + +```typescript +// src/api/tracking/scrobble-middleware.ts + +import { useWatchHistoryStore, WatchHistoryItem } from '@/store/watch-history. store'; +import { useSimklStore } from '@/store/simkl.store'; +import { SCROBBLE_THRESHOLDS, SCROBBLE_DEBOUNCE_MS } from '@/constants/tracking'; +import { createCompositeKey } from '@/utils/video-id'; +import { scrobbleToSimkl, finishScrobbleOnSimkl } from '@/api/simkl/scrobble'; +import { canTrackMedia } from '@/api/simkl/lookup'; +import { createDebugLogger } from '@/utils/debug'; + +const debug = createDebugLogger('ScrobbleMiddleware'); + +let debounceTimer: ReturnType | null = null; + +const getProgressRatio = (item: WatchHistoryItem): number => { + if (item.durationSeconds <= 0) return 0; + return item.progressSeconds / item.durationSeconds; +}; + +const handleSimklScrobble = async (item: WatchHistoryItem): Promise => { + const simkl = useSimklStore.getState(); + + if (!simkl.isConnected || !simkl.scrobblingEnabled) return; + + if (!canTrackMedia(item.id)) { + debug('cannotTrack', { id: item.id, reason: 'no valid ID' }); + return; + } + + const mediaKey = createCompositeKey(item.id, item.videoId); + const progressRatio = getProgressRatio(item); + const session = simkl.getOrCreateSession(mediaKey); + + // Start scrobble at threshold + if (progressRatio >= SCROBBLE_THRESHOLDS.START && !session.hasStarted) { + debug('scrobbleStart', { mediaKey, progressRatio }); + simkl.updateSession(mediaKey, { hasStarted: true, lastProgress: progressRatio }); + + try { + await scrobbleToSimkl(item, progressRatio); + } catch (error) { + debug('scrobbleStartFailed', { mediaKey, error }); + simkl.addToOfflineQueue({ + type: 'scrobble-start', + payload: { item, progressRatio }, + createdAt: Date.now(), + }); + } + } + + // Finish scrobble at threshold + if (progressRatio >= SCROBBLE_THRESHOLDS.FINISH && !session.hasFinished) { + debug('scrobbleFinish', { mediaKey, progressRatio }); + simkl.updateSession(mediaKey, { hasFinished: true }); + + try { + await finishScrobbleOnSimkl(item); + simkl.clearSession(mediaKey); + } catch (error) { + debug('scrobbleFinishFailed', { mediaKey, error }); + simkl.addToOfflineQueue({ + type: 'scrobble-finish', + payload: { item }, + createdAt: Date.now(), + }); + } + } + + simkl.updateSession(mediaKey, { lastProgress: progressRatio }); +}; + +/** + * Initialize scrobble middleware + * Subscribes to watch history store and triggers scrobbles + */ +export const initializeScrobbleMiddleware = (): (() => void) => { + useSimklStore.getState().clearExpiredSessions(); + + const unsubscribe = useWatchHistoryStore.subscribe( + (state) => state.byProfile, + (byProfile, prevByProfile) => { + const activeProfileId = useWatchHistoryStore.getState().activeProfileId; + if (!activeProfileId) return; + + const currentItems = byProfile[activeProfileId] ?? {}; + const prevItems = prevByProfile[activeProfileId] ?? {}; + + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + debounceTimer = setTimeout(() => { + for (const [key, item] of Object.entries(currentItems)) { + const prevItem = prevItems[key]; + + if ( + prevItem && + prevItem.progressSeconds === item.progressSeconds && + prevItem.lastWatchedAt === item.lastWatchedAt + ) { + continue; + } + + handleSimklScrobble(item).catch((err) => { + debug('handleScrobbleFailed', { key, error: err }); + }); + } + }, SCROBBLE_DEBOUNCE_MS); + }, + { fireImmediately: false } + ); + + debug('initialized'); + + return () => { + unsubscribe(); + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debug('destroyed'); + }; +}; +``` + +--- + +## Sync Manager (Functional) + +```typescript +// src/api/tracking/sync. ts + +import NetInfo from '@react-native-community/netinfo'; +import { AppState, AppStateStatus } from 'react-native'; +import { useSimklStore } from '@/store/simkl.store'; +import { useTrackingStore } from '@/store/tracking.store'; +import { pullFromSimkl, pushToSimkl, processSimklOfflineQueue } from '@/api/simkl/sync'; +import { SYNC_INTERVAL_MS } from '@/constants/tracking'; +import { createDebugLogger } from '@/utils/debug'; + +const debug = createDebugLogger('Sync'); + +let lastSyncAt = 0; +let isSyncing = false; +let appStateSubscription: ReturnType | null = null; +let netInfoSubscription: ReturnType | null = null; + +type Provider = { + id: string; + pull: () => Promise; + push: () => Promise; + processQueue: () => Promise; +}; + +const getEnabledProviders = (): Provider[] => { + const providers: Provider[] = []; + + const simkl = useSimklStore.getState(); + if (simkl.isConnected) { + providers.push({ + id: 'simkl', + pull: pullFromSimkl, + push: pushToSimkl, + processQueue: processSimklOfflineQueue, + }); + } + + // Future: Add Trakt here + + return providers; +}; + +export const performSync = async (): Promise<{ pulled: number; pushed: number }> => { + if (isSyncing) { + debug('syncSkipped', { reason: 'already syncing' }); + return { pulled: 0, pushed: 0 }; + } + + const providers = getEnabledProviders(); + if (providers.length === 0) { + debug('syncSkipped', { reason: 'no providers' }); + return { pulled: 0, pushed: 0 }; + } + + const { autoSyncEnabled } = useTrackingStore.getState(); + if (!autoSyncEnabled) { + debug('syncSkipped', { reason: 'auto-sync disabled' }); + return { pulled: 0, pushed: 0 }; + } + + isSyncing = true; + useTrackingStore.getState().setSyncState('syncing'); + + let totalPulled = 0; + let totalPushed = 0; + + try { + for (const provider of providers) { + try { + const pulled = await provider.pull(); + totalPulled += pulled; + debug('pullComplete', { provider: provider.id, pulled }); + + const pushed = await provider.push(); + totalPushed += pushed; + debug('pushComplete', { provider: provider.id, pushed }); + } catch (error) { + debug('providerSyncFailed', { provider: provider.id, error }); + } + } + + lastSyncAt = Date.now(); + useTrackingStore.getState().setLastSyncAt(lastSyncAt); + useTrackingStore.getState().setSyncStats({ + itemsPulled: totalPulled, + itemsPushed: totalPushed, + }); + useTrackingStore.getState().setSyncState('idle'); + + debug('syncComplete', { pulled: totalPulled, pushed: totalPushed }); + } catch (error) { + useTrackingStore.getState().setSyncState('error'); + debug('syncFailed', { error }); + } finally { + isSyncing = false; + } + + return { pulled: totalPulled, pushed: totalPushed }; +}; + +export const triggerSyncIfNeeded = async (): Promise => { + const timeSinceLastSync = Date.now() - lastSyncAt; + if (timeSinceLastSync < SYNC_INTERVAL_MS) { + debug('syncSkipped', { reason: 'too recent', timeSinceLastSync }); + return; + } + + await performSync(); +}; + +const processOfflineQueues = async (): Promise => { + const providers = getEnabledProviders(); + for (const provider of providers) { + try { + await provider.processQueue(); + } catch (error) { + debug('offlineQueueFailed', { provider: provider.id, error }); + } + } +}; + +export const initializeSyncListeners = (): (() => void) => { + const handleAppStateChange = (state: AppStateStatus) => { + if (state === 'active') { + triggerSyncIfNeeded(); + } + }; + + appStateSubscription = AppState.addEventListener('change', handleAppStateChange); + + netInfoSubscription = NetInfo.addEventListener((state) => { + if (state.isConnected) { + processOfflineQueues(); + triggerSyncIfNeeded(); + } else { + useTrackingStore.getState().setSyncState('offline'); + } + }); + + debug('listenersInitialized'); + + return () => { + appStateSubscription?.remove(); + netInfoSubscription?.(); + debug('listenersDestroyed'); + }; +}; +``` + +--- + +## Sync Status Indicator (Continue Watching Header) + +```typescript +// src/components/tracking/TrackingSyncIndicator.tsx + +import React from 'react'; +import type { FC } from 'react'; +import { ActivityIndicator } from 'react-native'; +import { Image } from 'expo-image'; +import { Box } from '@/theme/theme'; +import { useTrackingStore } from '@/store/tracking. store'; +import { useSimklStore } from '@/store/simkl.store'; + +const SIMKL_ICON_URL = 'https://simkl.com/favicon.ico'; + +type SyncState = 'idle' | 'syncing' | 'error' | 'offline'; + +interface ProviderIndicatorProps { + iconUrl: string; + syncState: SyncState; + providerName: string; +} + +const ProviderIndicator: FC = ({ + iconUrl, + syncState, + providerName, +}) => { + const getIndicatorColor = (): string => { + switch (syncState) { + case 'syncing': + return 'primary'; + case 'error': + return 'error'; + case 'offline': + return 'warning'; + default: + return 'success'; + } + }; + + return ( + + + + + {syncState === 'syncing' && } + + + ); +}; + +export const TrackingSyncIndicator: FC = () => { + const simklConnected = useSimklStore((s) => s.isConnected); + const syncState = useTrackingStore((s) => s.syncState); + + if (!simklConnected) { + return null; + } + + return ( + + {simklConnected && ( + + )} + + ); +}; +``` + +--- + +## Update Home Screen Section Header + +```typescript +// src/app/(tabs)/index.tsx - Update HomeSectionHeader + +import { TrackingSyncIndicator } from '@/components/tracking/TrackingSyncIndicator'; + +interface HomeSectionHeaderProps { + section: SectionModel; +} + +const HomeSectionHeader: FC = ({ section }) => ( + + + {section.title} + {section.type && ( + + {section.type} + + )} + + + {/* Show sync indicator on Continue Watching section */} + {section. key === 'continue-watching' && } + +); +``` + +--- + +## Settings: Integrations Page + +### Add to Menu + +```typescript +// src/constants/settings.ts - Add integrations + +export const SETTINGS_MENU_ITEMS: SettingsMenuItem[] = [ + // ... existing items ... + { + id: 'integrations', + title: 'Integrations', + description: 'Connect Simkl, Trakt for cross-device sync', + icon: 'cloud-outline', + href: '/settings/integrations', + }, +]; +``` + +### Main Content + +```typescript +// src/components/settings/IntegrationsSettingsContent.tsx + +import React from 'react'; +import type { FC } from 'react'; +import { ScrollView } from 'react-native'; +import { Box, Text } from '@/theme/theme'; +import { SimklIntegrationSection } from './integrations/SimklIntegrationSection'; +import { TraktComingSoonSection } from './integrations/TraktComingSoonSection'; +import { SyncSettingsSection } from './integrations/SyncSettingsSection'; + +export const IntegrationsSettingsContent: FC = () => { + return ( + + + + + Integrations + + + Connect external services to sync watch history across devices. + + + + + + + + + ); +}; +``` + +### Simkl Section + +```typescript +// src/components/settings/integrations/SimklIntegrationSection.tsx + +import React, { useState, useCallback } from 'react'; +import type { FC } from 'react'; +import { Image } from 'expo-image'; +import { Box, Text } from '@/theme/theme'; +import { Switch } from '@/components/basic/Switch'; +import { Focusable } from '@/components/basic/Focusable'; +import { useSimklStore } from '@/store/simkl.store'; +import { isSimklConfigured } from '@/utils/env'; +import { SimklPinAuthFlow } from './SimklPinAuthFlow'; + +const SIMKL_LOGO_URL = 'https://simkl.com/favicon. ico'; + +interface ProviderHeaderProps { + logoUrl: string; + name: string; + description: string; + isConnected: boolean; +} + +const ProviderHeader: FC = ({ + logoUrl, + name, + description, + isConnected, +}) => ( + + + + + {name} + + + {description} + + + + + {isConnected ? 'Connected' : 'Not connected'} + + + +); + +interface ConnectedStateProps { + userName?: string; + scrobblingEnabled: boolean; + onScrobblingChange: (enabled: boolean) => void; + onDisconnect: () => void; +} + +const ConnectedState: FC = ({ + userName, + scrobblingEnabled, + onScrobblingChange, + onDisconnect, +}) => ( + + + + Logged in as: + + + {userName ?? 'Unknown'} + + + + + + + Scrobbling + + + Track "Now Watching" status + + + + + + + + + Disconnect + + + + +); + +export const SimklIntegrationSection: FC = () => { + const isConnected = useSimklStore((s) => s.isConnected); + const user = useSimklStore((s) => s.user); + const scrobblingEnabled = useSimklStore((s) => s.scrobblingEnabled); + const setScrobblingEnabled = useSimklStore((s) => s.setScrobblingEnabled); + const disconnect = useSimklStore((s) => s.disconnect); + + const [showAuth, setShowAuth] = useState(false); + + const handleConnected = useCallback(() => { + setShowAuth(false); + }, []); + + const handleCancel = useCallback(() => { + setShowAuth(false); + }, []); + + if (!isSimklConfigured()) { + return ( + + + Simkl integration not configured + + + ); + } + + return ( + + + + {! isConnected ? ( + showAuth ? ( + + ) : ( + setShowAuth(true)}> + + + Connect to Simkl + + + + ) + ) : ( + + )} + + ); +}; +``` + +### Simkl PIN Auth Flow + +```typescript +// src/components/settings/integrations/SimklPinAuthFlow.tsx + +import React, { useState, useEffect, useCallback } from 'react'; +import type { FC } from 'react'; +import { Box, Text } from '@/theme/theme'; +import { Focusable } from '@/components/basic/Focusable'; +import { LoadingIndicator } from '@/components/basic/LoadingIndicator'; +import { requestPinCode, pollPinStatus, completePinAuth } from '@/api/simkl/auth'; + +type AuthState = 'idle' | 'loading' | 'showing-pin' | 'polling' | 'success' | 'error' | 'expired'; + +interface SimklPinAuthFlowProps { + onComplete: () => void; + onCancel: () => void; +} + +export const SimklPinAuthFlow: FC = ({ onComplete, onCancel }) => { + const [state, setState] = useState('idle'); + const [pinCode, setPinCode] = useState(''); + const [verificationUrl, setVerificationUrl] = useState(''); + const [error, setError] = useState(''); + const [abortController, setAbortController] = useState(null); + + const startAuth = useCallback(async () => { + setState('loading'); + setError(''); + + try { + const pinData = await requestPinCode(); + setPinCode(pinData.user_code); + setVerificationUrl(pinData.verification_url); + setState('showing-pin'); + + const controller = new AbortController(); + setAbortController(controller); + setState('polling'); + + const accessToken = await pollPinStatus(pinData.user_code, { + interval: pinData.interval, + expiresIn: pinData.expires_in, + signal: controller.signal, + }); + + const success = await completePinAuth(accessToken); + + if (success) { + setState('success'); + setTimeout(onComplete, 1500); + } else { + setState('error'); + setError('Failed to get user info'); + } + } catch (err) { + if (err instanceof Error) { + if (err.message === 'PIN code expired') { + setState('expired'); + } else if (err.message === 'PIN auth cancelled') { + setState('idle'); + } else { + setState('error'); + setError(err.message); + } + } else { + setState('error'); + setError('Unknown error'); + } + } + }, [onComplete]); + + const cancelAuth = useCallback(() => { + abortController?.abort(); + onCancel(); + }, [abortController, onCancel]); + + useEffect(() => { + startAuth(); + + return () => { + abortController?.abort(); + }; + }, []); + + if (state === 'loading') { + return ( + + + + Getting PIN code... + + + ); + } + + if (state === 'showing-pin' || state === 'polling') { + return ( + + + + On your phone or computer, go to: + + + {verificationUrl} + + + And enter this code: + + + {pinCode} + + + + {state === 'polling' && ( + + + + Waiting for authorization... + + + )} + + + + + Cancel + + + + + ); + } + + if (state === 'success') { + return ( + + + ✓ Connected! + + + Your watch history will now sync across all your devices. + + + ); + } + + if (state === 'expired') { + return ( + + + PIN code expired + + + + + Try Again + + + + + + + Cancel + + + + + ); + } + + if (state === 'error') { + return ( + + + {error || 'Something went wrong'} + + + + + Try Again + + + + + + + Cancel + + + + + ); + } + + return null; +}; +``` + +### Trakt Coming Soon + +```typescript +// src/components/settings/integrations/TraktComingSoonSection.tsx + +import React from 'react'; +import type { FC } from 'react'; +import { Box, Text } from '@/theme/theme'; + +export const TraktComingSoonSection: FC = () => ( + + + + + T + + + + + Trakt + + + Coming soon + + + + +); +``` + +### Sync Settings Section + +```typescript +// src/components/settings/integrations/SyncSettingsSection.tsx + +import React, { useState, useCallback } from 'react'; +import type { FC } from 'react'; +import { Box, Text } from '@/theme/theme'; +import { Switch } from '@/components/basic/Switch'; +import { Focusable } from '@/components/basic/Focusable'; +import { useTrackingStore } from '@/store/tracking. store'; +import { useSimklStore } from '@/store/simkl.store'; +import { performSync } from '@/api/tracking/sync'; +import { formatDistanceToNow } from 'date-fns'; + +export const SyncSettingsSection: FC = () => { + const simklConnected = useSimklStore((s) => s.isConnected); + + const autoSyncEnabled = useTrackingStore((s) => s.autoSyncEnabled); + const setAutoSyncEnabled = useTrackingStore((s) => s.setAutoSyncEnabled); + const lastSyncAt = useTrackingStore((s) => s.lastSyncAt); + const syncStats = useTrackingStore((s) => s.syncStats); + const syncState = useTrackingStore((s) => s.syncState); + + const [isSyncing, setIsSyncing] = useState(false); + + const hasConnectedProvider = simklConnected; + if (!hasConnectedProvider) return null; + + const handleSyncNow = useCallback(async () => { + setIsSyncing(true); + try { + await performSync(); + } finally { + setIsSyncing(false); + } + }, []); + + const isCurrentlySyncing = isSyncing || syncState === 'syncing'; + + return ( + + + Sync Settings + + + + + + Auto-Sync + + + Sync automatically on app launch + + + + + + + + Last synced:{' '} + {lastSyncAt ? formatDistanceToNow(lastSyncAt, { addSuffix: true }) : 'Never'} + + {syncStats && ( + <> + + Items pulled: {syncStats. itemsPulled} + + + Items pushed: {syncStats. itemsPushed} + + + )} + {syncState === 'error' && syncStats?. lastError && ( + + Error: {syncStats.lastError} + + )} + + + + + + {isCurrentlySyncing ? 'Syncing...' : 'Sync Now'} + + + + + ); +}; +``` + +--- + +## App Layout Integration + +```typescript +// src/app/_layout.tsx - Add initialization + +import { useEffect } from 'react'; +import { initializeScrobbleMiddleware } from '@/api/tracking/scrobble-middleware'; +import { initializeSyncListeners, triggerSyncIfNeeded } from '@/api/tracking/sync'; + +export default function RootLayout() { + // ... existing code ... + + useEffect(() => { + // Initialize scrobble middleware (watches store changes) + const unsubscribeScrobble = initializeScrobbleMiddleware(); + + // Initialize sync listeners (app state, network) + const unsubscribeSync = initializeSyncListeners(); + + // Trigger initial sync + triggerSyncIfNeeded(); + + return () => { + unsubscribeScrobble(); + unsubscribeSync(); + }; + }, []); + + // ... rest of component +} +``` + +--- + +## File Structure + +``` +src/ +├── api/ +│ ├── simkl/ +│ │ ├── auth.ts # PIN auth functions +│ │ ├── client.ts # API client functions +│ │ ├── lookup.ts # Media ID resolution +│ │ ├── scrobble. ts # Scrobble API calls +│ │ └── sync.ts # Sync API calls +│ └── tracking/ +│ ├── scrobble-middleware. ts # Store subscription +│ └── sync. ts # Generic sync manager +├── components/ +│ ├── settings/ +│ │ ├── IntegrationsSettingsContent.tsx +│ │ └── integrations/ +│ │ ├── SimklIntegrationSection.tsx +│ │ ├── SimklPinAuthFlow.tsx +│ │ ├── SyncSettingsSection. tsx +│ │ └── TraktComingSoonSection. tsx +│ └── tracking/ +│ └── TrackingSyncIndicator.tsx +├── constants/ +│ └── tracking.ts # Thresholds, intervals +├── store/ +│ ├── simkl.store. ts # Simkl state + scrobble sessions +│ └── tracking.store.ts # Global tracking state +└── utils/ + ├── env.ts # Environment helpers + └── video-id.ts # ID parsing/formatting +``` + +--- + +## Summary + +| Feature | Implementation | +| ------------------- | -------------------------------------------------------- | +| Environment config | `EXPO_PUBLIC_SIMKL_CLIENT_ID` via `src/utils/env.ts` | +| Auth flow | PIN only (all devices), no OAuth | +| Components | Arrow functions with `FC` types | +| Video ID utils | `createVideoId`, `parseVideoId`, `createCompositeKey` | +| Non-IMDB matching | Simkl lookup by TMDB/TVDB/title, skip if no match | +| Scrobble state | In `useSimklStore. scrobbleSessions` | +| Reactive scrobbling | Store middleware via `subscribeWithSelector` | +| Sync indicator | `TrackingSyncIndicator` in Continue Watching header | +| Settings | Separate file-local components in `integrations/` folder | + +--- + +## References + +- **REF-001**: [Simkl API Documentation](https://simkl.docs.apiary.io/) +- **REF-002**: [Simkl PIN Authentication](https://simkl.docs.apiary.io/#reference/authentication-pin) +- **REF-003**: [DodoStream playback. ts](https://github.com/Kombustor/dodostream/blob/main/src/constants/playback.ts) +- **REF-004**: [DodoStream settings](https://github.com/Kombustor/dodostream/blob/main/src/constants/settings. ts) diff --git a/src/api/simkl/auth.ts b/src/api/simkl/auth.ts new file mode 100644 index 0000000..af461b3 --- /dev/null +++ b/src/api/simkl/auth.ts @@ -0,0 +1,12 @@ +import { simklRequest } from '@/api/simkl/client'; +import type { SimklPinCodeResponse, SimklPinPollResponse } from '@/api/simkl/types'; + +export const simklCreatePin = async (): Promise => { + // GET /oauth/pin?client_id=... + return simklRequest('/oauth/pin'); +}; + +export const simklPollPin = async (userCode: string): Promise => { + // GET /oauth/pin/{USER_CODE}?client_id=... + return simklRequest(`/oauth/pin/${encodeURIComponent(userCode)}`); +}; diff --git a/src/api/simkl/client.ts b/src/api/simkl/client.ts new file mode 100644 index 0000000..404f66f --- /dev/null +++ b/src/api/simkl/client.ts @@ -0,0 +1,82 @@ +import { getSimklClientId } from '@/utils/env'; +import { createDebugLogger } from '@/utils/debug'; +import { SimklApiError } from '@/api/simkl/errors'; + +const debug = createDebugLogger('SimklApi'); + +const SIMKL_API_BASE_URL = 'https://api.simkl.com'; + +export interface SimklRequestOptions { + method?: 'GET' | 'POST' | 'DELETE'; + token?: string; + query?: Record; + body?: unknown; +} + +const buildQuery = (query?: SimklRequestOptions['query']): string => { + if (!query) return ''; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + params.set(key, String(value)); + } + const s = params.toString(); + return s.length > 0 ? `?${s}` : ''; +}; + +export const simklRequest = async (path: string, options: SimklRequestOptions = {}): Promise => { + const method = options.method ?? 'GET'; + const clientId = getSimklClientId(); + + const query = { ...options.query }; + // Many Simkl endpoints accept client_id as a query parameter. + // We still also send simkl-api-key for token-required endpoints. + if (!('client_id' in query)) { + query.client_id = clientId; + } + + const url = `${SIMKL_API_BASE_URL}${path}${buildQuery(query)}`; + + const headers: Record = { + Accept: 'application/json', + }; + + if (options.token) { + headers.Authorization = `Bearer ${options.token}`; + headers['simkl-api-key'] = clientId; + } + + const init: RequestInit = { + method, + headers, + }; + + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(options.body); + } + + debug('request', { method, path, url, hasToken: !!options.token }); + + let response: Response; + try { + response = await fetch(url, init); + } catch (error) { + throw SimklApiError.fromError(error, url); + } + + if (!response.ok) { + throw SimklApiError.fromResponse(response, url); + } + + // 204 responses have no body. + if (response.status === 204) { + return undefined as T; + } + + try { + return (await response.json()) as T; + } catch (error) { + throw SimklApiError.fromError(error, url, 'Failed to parse JSON'); + } +}; diff --git a/src/api/simkl/errors.ts b/src/api/simkl/errors.ts new file mode 100644 index 0000000..b83aa6d --- /dev/null +++ b/src/api/simkl/errors.ts @@ -0,0 +1,29 @@ +export class SimklApiError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + public readonly endpoint?: string, + public readonly originalError?: unknown + ) { + super(message); + this.name = 'SimklApiError'; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, SimklApiError); + } + } + + static fromResponse(response: Response, endpoint: string): SimklApiError { + return new SimklApiError( + `Request failed: ${response.status} ${response.statusText}`, + response.status, + endpoint + ); + } + + static fromError(error: unknown, endpoint: string, message?: string): SimklApiError { + const errorMessage = + message ?? (error instanceof Error ? error.message : 'An unknown error occurred'); + return new SimklApiError(errorMessage, undefined, endpoint, error); + } +} diff --git a/src/api/simkl/index.ts b/src/api/simkl/index.ts new file mode 100644 index 0000000..1d04443 --- /dev/null +++ b/src/api/simkl/index.ts @@ -0,0 +1,7 @@ +export * from './auth'; +export * from './client'; +export * from './errors'; +export * from './lookup'; +export * from './scrobble'; +export * from './sync'; +export * from './types'; diff --git a/src/api/simkl/lookup.ts b/src/api/simkl/lookup.ts new file mode 100644 index 0000000..6235b8a --- /dev/null +++ b/src/api/simkl/lookup.ts @@ -0,0 +1,153 @@ +import { getSimklClientId } from '@/utils/env'; +import { isImdbId } from '@/utils/video-id'; +import { createDebugLogger } from '@/utils/debug'; +import type { SimklIdObject } from '@/api/simkl/types'; + +const debug = createDebugLogger('SimklLookup'); + +const SIMKL_API_URL = 'https://api.simkl.com'; + +export interface LookupResult { + ids: SimklIdObject; + title?: string; + year?: number; +} + +export interface LookupMetadata { + title?: string; + year?: number; + type?: 'movie' | 'show' | 'anime'; + tmdbId?: number; + tvdbId?: number; +} + +/** + * Lookup a media item on Simkl by various IDs + * + * Simkl supports lookup by: imdb, tmdb, tvdb, mal, anidb, hulu, netflix, etc. + * If we only have a non-standard ID, we try title+year search as fallback. + */ +export const lookupMedia = async ( + mediaId: string, + metadata?: LookupMetadata +): Promise => { + // Priority 1: IMDB ID - direct lookup + if (isImdbId(mediaId)) { + return { ids: { imdb: mediaId } }; + } + + // Priority 2: TMDB/TVDB ID if provided in metadata + if (metadata?.tmdbId) { + const result = await lookupByExternalId('tmdb', metadata.tmdbId, metadata.type); + if (result) return result; + } + + if (metadata?.tvdbId) { + const result = await lookupByExternalId('tvdb', metadata.tvdbId, metadata.type); + if (result) return result; + } + + // Priority 3: Title + year search + if (metadata?.title) { + const result = await searchByTitle(metadata.title, metadata.year, metadata.type); + if (result) return result; + } + + // No match found - cannot track this item + debug('noMatch', { mediaId, metadata }); + return null; +}; + +const lookupByExternalId = async ( + idType: 'tmdb' | 'tvdb' | 'mal', + idValue: number, + type?: 'movie' | 'show' | 'anime' +): Promise => { + try { + const params = new URLSearchParams({ + [idType]: String(idValue), + client_id: getSimklClientId(), + }); + + if (type) { + params.set('type', type === 'show' ? 'tv' : type); + } + + const response = await fetch(`${SIMKL_API_URL}/search/id?${params}`); + + if (!response.ok) { + debug('lookupByExternalIdFailed', { idType, idValue, status: response.status }); + return null; + } + + const data = await response.json(); + if (!data || data.length === 0) return null; + + return { + ids: data[0].ids, + title: data[0].title, + year: data[0].year, + }; + } catch (error) { + debug('lookupByExternalIdError', { idType, idValue, error }); + return null; + } +}; + +const searchByTitle = async ( + title: string, + year?: number, + type?: 'movie' | 'show' | 'anime' +): Promise => { + try { + const endpoint = type === 'movie' ? '/search/movie' : '/search/tv'; + const params = new URLSearchParams({ + q: title, + client_id: getSimklClientId(), + }); + + if (year) { + params.set('year', String(year)); + } + + const response = await fetch(`${SIMKL_API_URL}${endpoint}?${params}`); + + if (!response.ok) { + debug('searchByTitleFailed', { title, status: response.status }); + return null; + } + + const data = await response.json(); + if (!data || data.length === 0) return null; + + return { + ids: data[0].ids, + title: data[0].title, + year: data[0].year, + }; + } catch (error) { + debug('searchByTitleError', { title, error }); + return null; + } +}; + +/** + * Check if we can track this media item + * Returns true if we have enough info to identify it on Simkl + */ +export const canTrackMedia = ( + mediaId: string, + metadata?: { title?: string; tmdbId?: number; tvdbId?: number } +): boolean => { + // IMDB IDs always work + if (isImdbId(mediaId)) return true; + + // TMDB/TVDB IDs work + if (metadata?.tmdbId || metadata?.tvdbId) return true; + + // Title search can work but is less reliable + if (metadata?.title) return true; + + // No way to identify this content + return false; +}; diff --git a/src/api/simkl/scrobble.ts b/src/api/simkl/scrobble.ts new file mode 100644 index 0000000..3f178de --- /dev/null +++ b/src/api/simkl/scrobble.ts @@ -0,0 +1,142 @@ +import type { ContentType } from '@/types/stremio'; +import { isImdbId, parseStremioVideoId } from '@/utils/video-id'; +import type { SimklScrobbleResponse } from '@/api/simkl/types'; +import { simklRequest } from '@/api/simkl/client'; +import { SIMKL_SCROBBLE_FINISHED_PERCENT } from '@/constants/tracking'; +import { createDebugLogger } from '@/utils/debug'; + +const debug = createDebugLogger('SimklScrobbleApi'); + +type ScrobbleAction = 'start' | 'pause' | 'stop'; + +const clampProgress = (percent: number): number => { + if (!Number.isFinite(percent)) return 0; + return Math.max(0, Math.min(100, percent)); +}; + +const buildScrobbleBody = (params: { + metaId: string; + contentType: ContentType; + videoId?: string; + progressPercent: number; +}): any => { + const { metaId, contentType, videoId, progressPercent } = params; + + const ids = isImdbId(metaId) ? { imdb: metaId } : undefined; + if (!ids) { + // Keep the request valid even if we can't identify the item by IMDB. + // Caller should avoid scrobbling in this case. + debug('buildScrobbleBodyNoImdb', { metaId, contentType, progressPercent }); + return { progress: clampProgress(progressPercent) }; + } + + if (contentType === 'movie') { + debug('buildScrobbleBodyMovie', { metaId, progressPercent }); + return { + progress: clampProgress(progressPercent), + movie: { ids }, + }; + } + + // Default to show episode for any non-movie content. + if (videoId) { + const parsed = parseStremioVideoId(videoId); + if (parsed) { + debug('buildScrobbleBodyEpisode', { metaId, season: parsed.season, episode: parsed.episode, progressPercent }); + return { + progress: clampProgress(progressPercent), + show: { ids }, + episode: { season: parsed.season, number: parsed.episode }, + }; + } + debug('buildScrobbleBodyVideoIdParseFailed', { metaId, videoId }); + } + + // Fallback: treat as show-level scrobble (less precise) + debug('buildScrobbleBodyShowFallback', { metaId, progressPercent }); + return { + progress: clampProgress(progressPercent), + show: { ids }, + }; +}; + +const scrobble = async (action: ScrobbleAction, params: { + token: string; + metaId: string; + contentType: ContentType; + videoId?: string; + progressSeconds: number; + durationSeconds: number; +}): Promise => { + const percent = params.durationSeconds > 0 ? (params.progressSeconds / params.durationSeconds) * 100 : 0; + + debug('scrobble', { + action, + metaId: params.metaId, + contentType: params.contentType, + videoId: params.videoId, + progressPercent: percent.toFixed(1), + progressSeconds: params.progressSeconds, + durationSeconds: params.durationSeconds, + }); + + const body = buildScrobbleBody({ + metaId: params.metaId, + contentType: params.contentType, + videoId: params.videoId, + progressPercent: percent, + }); + + const response = await simklRequest(`/scrobble/${action}`, { + method: 'POST', + token: params.token, + body, + }); + + debug('scrobbleResponse', { action, metaId: params.metaId, response }); + return response; +}; + +export const simklScrobbleStart = async (params: { + token: string; + metaId: string; + contentType: ContentType; + videoId?: string; + progressSeconds: number; + durationSeconds: number; +}): Promise => { + return scrobble('start', params); +}; + +export const simklScrobblePause = async (params: { + token: string; + metaId: string; + contentType: ContentType; + videoId?: string; + progressSeconds: number; + durationSeconds: number; +}): Promise => { + return scrobble('pause', params); +}; + +export const simklScrobbleStop = async (params: { + token: string; + metaId: string; + contentType: ContentType; + videoId?: string; + progressSeconds: number; + durationSeconds: number; +}): Promise => { + return scrobble('stop', params); +}; + +export const isSimklFinishedScrobble = (progressSeconds: number, durationSeconds: number): boolean => { + if (durationSeconds <= 0) { + debug('isSimklFinishedScrobble', { result: false, reason: 'no-duration', progressSeconds, durationSeconds }); + return false; + } + const percent = (progressSeconds / durationSeconds) * 100; + const result = percent >= SIMKL_SCROBBLE_FINISHED_PERCENT; + debug('isSimklFinishedScrobble', { result, percent: percent.toFixed(1), threshold: SIMKL_SCROBBLE_FINISHED_PERCENT }); + return result; +}; diff --git a/src/api/simkl/sync.ts b/src/api/simkl/sync.ts new file mode 100644 index 0000000..f554bc5 --- /dev/null +++ b/src/api/simkl/sync.ts @@ -0,0 +1,156 @@ +import { simklRequest } from '@/api/simkl/client'; +import type { SimklActivitiesResponse, SimklAllItemsResponse, SimklPlaybackItem } from '@/api/simkl/types'; +import type { ContentType } from '@/types/stremio'; +import { isImdbId, parseStremioVideoId } from '@/utils/video-id'; +import { createDebugLogger } from '@/utils/debug'; + +const debug = createDebugLogger('SimklSyncApi'); + +export const simklGetActivities = async (token: string): Promise => { + debug('getActivities', { hasToken: !!token }); + const response = await simklRequest('/sync/activities', { method: 'POST', token }); + debug('getActivitiesResponse', { all: response.all }); + return response; +}; + +export type SimklPlaybackType = 'movies' | 'episodes'; + +export const simklGetPlaybackSessions = async (params: { + token: string; + type?: SimklPlaybackType; + /** ISO date string to pull only updates since that time (incremental sync). */ + dateFromIso?: string; +}): Promise => { + debug('getPlaybackSessions', { type: params.type, dateFromIso: params.dateFromIso }); + const path = params.type ? `/sync/playback/${params.type}` : '/sync/playback'; + const response = await simklRequest(path, { + token: params.token, + query: params.dateFromIso ? { date_from: params.dateFromIso } : undefined, + }); + debug('getPlaybackSessionsResponse', { count: response.length }); + return response; +}; + +export const simklDeletePlaybackSession = async (token: string, id: number): Promise => { + debug('deletePlaybackSession', { id }); + await simklRequest(`/sync/playback/${id}`, { method: 'DELETE', token }); + debug('deletePlaybackSessionComplete', { id }); +}; + +export const simklAddToHistory = async (params: { + token: string; + metaId: string; + contentType: ContentType; + videoId?: string; + watchedAtIso?: string; +}): Promise => { + debug('addToHistory', { + metaId: params.metaId, + contentType: params.contentType, + videoId: params.videoId, + watchedAtIso: params.watchedAtIso, + }); + + const ids = isImdbId(params.metaId) ? { imdb: params.metaId } : undefined; + if (!ids) { + // Caller should avoid using this when we can't provide reliable IDs. + debug('addToHistoryNoImdb', { metaId: params.metaId }); + return simklRequest('/sync/history', { method: 'POST', token: params.token, body: {} }); + } + + if (params.contentType === 'movie') { + debug('addToHistoryMovie', { metaId: params.metaId }); + return simklRequest('/sync/history', { + method: 'POST', + token: params.token, + body: { + movies: [ + { + ids, + watched_at: params.watchedAtIso, + }, + ], + }, + }); + } + + const parsed = params.videoId ? parseStremioVideoId(params.videoId) : undefined; + if (parsed) { + debug('addToHistoryEpisode', { metaId: params.metaId, season: parsed.season, episode: parsed.episode }); + return simklRequest('/sync/history', { + method: 'POST', + token: params.token, + body: { + episodes: [ + { + watched_at: params.watchedAtIso, + show: { ids }, + episode: { season: parsed.season, number: parsed.episode }, + }, + ], + }, + }); + } + + debug('addToHistoryShowFallback', { metaId: params.metaId }); + return simklRequest('/sync/history', { + method: 'POST', + token: params.token, + body: { + shows: [ + { + ids, + }, + ], + }, + }); +}; + +/** + * Fetches all items from the user's watchlist (watched history). + * According to Simkl docs: + * - First sync: call WITHOUT date_from to get the full history + * - Subsequent syncs: call WITH date_from to get only updates since that time + * - Returns null if there are no items or no updates since date_from + */ +export const simklGetAllItems = async (params: { + token: string; + /** Optional type filter: 'shows', 'anime', 'movies' */ + type?: 'shows' | 'anime' | 'movies'; + /** ISO date string for incremental sync (use activity timestamp from previous sync) */ + dateFromIso?: string; + /** If true, includes watched episode details */ + extended?: boolean; +}): Promise => { + const path = params.type ? `/sync/all-items/${params.type}/` : '/sync/all-items/'; + + const query: Record = {}; + if (params.dateFromIso) { + query.date_from = params.dateFromIso; + } + if (params.extended) { + query.extended = 'full'; + } + + debug('getAllItems', { + type: params.type ?? 'all', + dateFromIso: params.dateFromIso, + extended: params.extended, + isFirstSync: !params.dateFromIso, + }); + + // Simkl returns null if there are no items or no updates since date_from + const response = await simklRequest(path, { + token: params.token, + query: Object.keys(query).length > 0 ? query : undefined, + }); + + debug('getAllItemsResponse', { + isNull: response === null, + showCount: response?.shows?.length ?? 0, + animeCount: response?.anime?.length ?? 0, + movieCount: response?.movies?.length ?? 0, + }); + + return response; +}; diff --git a/src/api/simkl/types.ts b/src/api/simkl/types.ts new file mode 100644 index 0000000..78c05ac --- /dev/null +++ b/src/api/simkl/types.ts @@ -0,0 +1,123 @@ +export type SimklIdObject = { + simkl?: number; + imdb?: string; + tmdb?: number | string; + tvdb?: number | string; + mal?: number | string; +}; + +export interface SimklMovieRef { + title?: string; + year?: number; + ids: SimklIdObject; +} + +export interface SimklShowRef { + title?: string; + year?: number; + ids: SimklIdObject; +} + +export interface SimklEpisodeRef { + season: number; + number: number; +} + +export interface SimklPinCodeResponse { + result?: 'OK' | 'KO'; + /** User code the user types on simkl.com/pin */ + user_code?: string; + /** URL user should open (often https://simkl.com/pin/) */ + verification_url?: string; + /** Optional: seconds until expiry */ + expires_in?: number; + /** Optional: recommended polling interval in seconds */ + interval?: number; + message?: string; +} + +export interface SimklPinPollResponse { + result?: 'OK' | 'KO'; + message?: string; + access_token?: string; +} + +export interface SimklScrobbleResponse { + id?: number; + action?: 'start' | 'pause' | 'scrobble'; + progress?: number; + watched_at?: string; + expires_at?: string; +} + +export interface SimklActivitiesResponse { + all?: string; + settings?: { all?: string }; + movies?: Record; + tv_shows?: Record; + anime?: Record; +} + +export interface SimklPlaybackItem { + id: number; + type: 'movie' | 'episode'; + progress: number; + paused_at?: string; + movie?: { ids?: SimklIdObject }; + show?: { ids?: SimklIdObject }; + anime?: { ids?: SimklIdObject }; + episode?: { season?: number; number?: number }; +} + +// ---------- /sync/all-items response types ---------- + +export interface SimklWatchedEpisode { + number: number; + watched_at?: string; +} + +export interface SimklWatchedSeason { + number: number; + episodes?: SimklWatchedEpisode[]; +} + +/** A show or anime item from the /sync/all-items response */ +export interface SimklAllItemsShow { + show?: { + title?: string; + year?: number; + ids?: SimklIdObject; + }; + /** Present for anime items */ + anime?: { + title?: string; + year?: number; + ids?: SimklIdObject; + anime_type?: string; + }; + status?: 'watching' | 'plantowatch' | 'completed' | 'hold' | 'dropped'; + last_watched_at?: string; + seasons?: SimklWatchedSeason[]; + /** If extended=full, flat episode list */ + watched?: SimklWatchedEpisode[]; + total_episodes_count?: number; + watched_episodes_count?: number; +} + +/** A movie item from the /sync/all-items response */ +export interface SimklAllItemsMovie { + movie?: { + title?: string; + year?: number; + ids?: SimklIdObject; + }; + status?: 'watching' | 'plantowatch' | 'completed' | 'hold' | 'dropped'; + last_watched_at?: string; +} + +/** Combined response from /sync/all-items */ +export interface SimklAllItemsResponse { + shows?: SimklAllItemsShow[]; + anime?: SimklAllItemsShow[]; + movies?: SimklAllItemsMovie[]; +} diff --git a/src/api/tracking/scrobble.ts b/src/api/tracking/scrobble.ts new file mode 100644 index 0000000..afe71e6 --- /dev/null +++ b/src/api/tracking/scrobble.ts @@ -0,0 +1,259 @@ +import { simklScrobblePause, simklScrobbleStart, simklScrobbleStop } from '@/api/simkl/scrobble'; +import { + SIMKL_SCROBBLE_DEBOUNCE_MS, + SIMKL_SCROBBLE_FINISHED_PERCENT, + SIMKL_SCROBBLE_MIN_INTERVAL_MS, + SIMKL_SCROBBLE_MIN_PROGRESS_DELTA_PERCENT, + SIMKL_SCROBBLE_START_PERCENT, +} from '@/constants/tracking'; +import type { WatchHistoryItem } from '@/store/watch-history.store'; +import { useWatchHistoryStore } from '@/store/watch-history.store'; +import { useSimklStore } from '@/store/simkl.store'; +import { useTrackingStore } from '@/store/tracking.store'; +import { createDebugLogger } from '@/utils/debug'; +import { isImdbId } from '@/utils/video-id'; + +const debug = createDebugLogger('SimklScrobble'); + +type ScrobbleSessionState = { + started: boolean; + stopped: boolean; + lastSentAt: number; + lastSentProgressPercent: number; +}; + +// TODO use the same key util at all locations +const getScrobbleKey = (item: WatchHistoryItem): string => { + const videoKey = item.videoId ?? '_'; + return `${item.id}::${videoKey}`; +}; + +const toProgressPercent = (item: WatchHistoryItem): number => { + if (item.durationSeconds <= 0) return 0; + return (item.progressSeconds / item.durationSeconds) * 100; +}; + +export const initializeSimklScrobbleMiddleware = (): (() => void) => { + debug('initialize'); + + let debounceTimer: ReturnType | undefined; + const staged = new Map(); + const sessions = new Map(); + + // Ensure Simkl scrobble calls are sent sequentially. + let scrobbleChain: Promise = Promise.resolve(); + const enqueue = (fn: () => Promise) => { + scrobbleChain = scrobbleChain + .then(fn) + .catch((error) => { + debug('scrobbleRequestFailed', { error }); + }); + }; + + let prevState = useWatchHistoryStore.getState(); + + const flush = () => { + if (staged.size === 0) return; + + const tracking = useTrackingStore.getState().getActiveTracking(); + if (!tracking.enabled || tracking.provider !== 'simkl') { + staged.clear(); + return; + } + + const simklState = useSimklStore.getState(); + const token = simklState.getAccessToken(); + if (!token) { + staged.clear(); + return; + } + + // Check if scrobbling is enabled for this profile + if (!simklState.isScrobblingEnabled()) { + debug('scrobblingDisabled'); + staged.clear(); + return; + } + + const items = Array.from(staged.values()); + staged.clear(); + + for (const item of items) { + const progressPercent = toProgressPercent(item); + + if (!isImdbId(item.id)) { + debug('skipNonImdbId', { metaId: item.id }); + continue; + } + + if (progressPercent < SIMKL_SCROBBLE_START_PERCENT) { + debug('skipBelowThreshold', { metaId: item.id, progressPercent: progressPercent.toFixed(1), threshold: SIMKL_SCROBBLE_START_PERCENT }); + continue; + } + + const key = getScrobbleKey(item); + const now = Date.now(); + const existing = sessions.get(key) ?? { + started: false, + stopped: false, + lastSentAt: 0, + lastSentProgressPercent: 0, + }; + + if (existing.stopped) { + debug('skipAlreadyStopped', { metaId: item.id, videoId: item.videoId }); + continue; + } + + const delta = Math.abs(progressPercent - existing.lastSentProgressPercent); + const timeSinceLast = now - existing.lastSentAt; + + if (progressPercent >= SIMKL_SCROBBLE_FINISHED_PERCENT) { + sessions.set(key, { + ...existing, + started: true, + stopped: true, + lastSentAt: now, + lastSentProgressPercent: progressPercent, + }); + + debug('queueStop', { + metaId: item.id, + videoId: item.videoId, + progressPercent, + }); + + enqueue(async () => { + await simklScrobbleStop({ + token, + metaId: item.id, + contentType: item.type, + videoId: item.videoId, + progressSeconds: item.progressSeconds, + durationSeconds: item.durationSeconds, + }); + }); + continue; + } + + if (!existing.started) { + sessions.set(key, { + ...existing, + started: true, + lastSentAt: now, + lastSentProgressPercent: progressPercent, + }); + + debug('queueStart', { + metaId: item.id, + videoId: item.videoId, + progressPercent, + }); + + enqueue(async () => { + await simklScrobbleStart({ + token, + metaId: item.id, + contentType: item.type, + videoId: item.videoId, + progressSeconds: item.progressSeconds, + durationSeconds: item.durationSeconds, + }); + }); + continue; + } + + if (timeSinceLast < SIMKL_SCROBBLE_MIN_INTERVAL_MS && delta < SIMKL_SCROBBLE_MIN_PROGRESS_DELTA_PERCENT) { + debug('skipUpdate', { metaId: item.id, reason: 'throttled', timeSinceLast, delta: delta.toFixed(1) }); + continue; + } + + sessions.set(key, { + ...existing, + lastSentAt: now, + lastSentProgressPercent: progressPercent, + }); + + debug('queuePause', { + metaId: item.id, + videoId: item.videoId, + progressPercent, + timeSinceLast, + delta, + }); + + enqueue(async () => { + await simklScrobblePause({ + token, + metaId: item.id, + contentType: item.type, + videoId: item.videoId, + progressSeconds: item.progressSeconds, + durationSeconds: item.durationSeconds, + }); + }); + } + }; + + const unsubscribe = useWatchHistoryStore.subscribe((nextState) => { + const nextProfileId = nextState.activeProfileId; + const prevProfileId = prevState.activeProfileId; + + if (!nextProfileId) { + prevState = nextState; + return; + } + + // Profile changed: reset session state to avoid cross-profile bleed. + if (prevProfileId !== nextProfileId) { + debug('profileChanged', { from: prevProfileId, to: nextProfileId }); + sessions.clear(); + staged.clear(); + prevState = nextState; + return; + } + + const currentProfileData = nextState.byProfile[nextProfileId] ?? {}; + const prevProfileData = prevState.byProfile[nextProfileId] ?? {}; + + for (const [metaId, metaItems] of Object.entries(currentProfileData)) { + const prevMetaItems = prevProfileData[metaId]; + if (prevMetaItems === metaItems) continue; + + for (const [videoKey, item] of Object.entries(metaItems)) { + const prevItem = prevMetaItems?.[videoKey]; + if ( + prevItem && + prevItem.progressSeconds === item.progressSeconds && + prevItem.durationSeconds === item.durationSeconds && + prevItem.lastWatchedAt === item.lastWatchedAt + ) { + continue; + } + + staged.set(`${metaId}::${videoKey}`, item); + } + } + + if (staged.size > 0) { + debug('stagedItems', { count: staged.size }); + } + + prevState = nextState; + + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(flush, SIMKL_SCROBBLE_DEBOUNCE_MS); + }); + + return () => { + debug('destroy'); + unsubscribe(); + if (debounceTimer) { + clearTimeout(debounceTimer); + } + staged.clear(); + sessions.clear(); + }; +}; diff --git a/src/api/tracking/sync.ts b/src/api/tracking/sync.ts new file mode 100644 index 0000000..c10b978 --- /dev/null +++ b/src/api/tracking/sync.ts @@ -0,0 +1,430 @@ +import { AppState, type AppStateStatus } from 'react-native'; +import * as Burnt from 'burnt'; +import { simklGetActivities, simklGetAllItems, simklGetPlaybackSessions } from '@/api/simkl/sync'; +import type { SimklAllItemsResponse, SimklPlaybackItem } from '@/api/simkl/types'; +import { SIMKL_SYNC_INTERVAL_MS } from '@/constants/tracking'; +import { useSimklStore } from '@/store/simkl.store'; +import { useTrackingStore } from '@/store/tracking.store'; +import { useWatchHistoryStore } from '@/store/watch-history.store'; +import { createDebugLogger } from '@/utils/debug'; +import { buildStremioEpisodeVideoId } from '@/utils/video-id'; +import { TOAST_DURATION_MEDIUM } from '@/constants/ui'; + +const debug = createDebugLogger('TrackingSync'); + +let isSyncing = false; +let intervalId: ReturnType | undefined; +let appStateSubscription: ReturnType | undefined; +let lastAppState: AppStateStatus | undefined; + +const clampRatio = (ratio: number): number => { + if (!Number.isFinite(ratio)) return 0; + return Math.max(0, Math.min(1, ratio)); +}; + +const getPlaybackKeyData = ( + item: SimklPlaybackItem +): { metaId: string; videoId?: string; type: 'movie' | 'series' } | undefined => { + if (item.type === 'movie') { + const imdb = item.movie?.ids?.imdb; + if (!imdb) { + debug('getPlaybackKeyData', { type: 'movie', result: 'skip', reason: 'no-imdb' }); + return undefined; + } + return { metaId: imdb, videoId: undefined, type: 'movie' }; + } + + const imdb = item.show?.ids?.imdb; + const season = item.episode?.season; + const episode = item.episode?.number; + if (!imdb || !season || !episode) { + debug('getPlaybackKeyData', { type: 'episode', result: 'skip', reason: 'missing-data', hasImdb: !!imdb, hasSeason: !!season, hasEpisode: !!episode }); + return undefined; + } + return { + metaId: imdb, + videoId: buildStremioEpisodeVideoId(imdb, season, episode), + type: 'series', + }; +}; + +const applySimklPlaybackToWatchHistory = async (items: SimklPlaybackItem[]): Promise => { + const activeProfileId = useWatchHistoryStore.getState().activeProfileId; + if (!activeProfileId) { + debug('applyPlayback', { result: 'skip', reason: 'no-profile' }); + return 0; + } + + debug('applyPlayback', { profileId: activeProfileId, itemCount: items.length }); + + let updated = 0; + for (const item of items) { + const keyData = getPlaybackKeyData(item); + if (!keyData) continue; + + const ratio = clampRatio((item.progress ?? 0) / 100); + if (ratio <= 0) { + debug('applyPlaybackItemSkip', { metaId: keyData.metaId, reason: 'zero-ratio' }); + continue; + } + + const pausedAtMs = item.paused_at ? new Date(item.paused_at).getTime() : NaN; + const lastWatchedAt = Number.isFinite(pausedAtMs) ? pausedAtMs : Date.now(); + + debug('applyPlaybackItem', { + metaId: keyData.metaId, + videoId: keyData.videoId, + type: keyData.type, + ratio: ratio.toFixed(2), + pausedAt: item.paused_at, + }); + + useWatchHistoryStore.getState().upsertItem({ + id: keyData.metaId, + type: keyData.type as any, + videoId: keyData.videoId, + progressSeconds: 0, + durationSeconds: 0, + progressRatio: ratio, + lastWatchedAt, + }); + updated += 1; + } + + debug('applyPlaybackComplete', { updated }); + return updated; +}; + +/** + * Process /sync/all-items response and update local watch history. + * This syncs the user's full watched history from Simkl. + * Note: Simkl returns null if there are no items/updates. + */ +const applySimklAllItemsToWatchHistory = async ( + data: SimklAllItemsResponse | null +): Promise => { + // Simkl returns null when there are no items or no updates since date_from + if (!data) { + debug('applyAllItems', { result: 'skip', reason: 'null-response' }); + return 0; + } + + const activeProfileId = useWatchHistoryStore.getState().activeProfileId; + if (!activeProfileId) { + debug('applyAllItems', { result: 'skip', reason: 'no-profile' }); + return 0; + } + + let updated = 0; + const { upsertItem } = useWatchHistoryStore.getState(); + + // Process movies + const movies = data.movies ?? []; + debug('applyAllItemsMovies', { count: movies.length }); + + for (const movieItem of movies) { + const imdb = movieItem.movie?.ids?.imdb; + if (!imdb) { + debug('applyAllItemsMovieSkip', { reason: 'no-imdb', title: movieItem.movie?.title }); + continue; + } + + // Only mark as watched if completed status + const isCompleted = movieItem.status === 'completed'; + const watchedAtMs = movieItem.last_watched_at + ? new Date(movieItem.last_watched_at).getTime() + : Date.now(); + + debug('applyAllItemsMovie', { + imdb, + status: movieItem.status, + isCompleted, + lastWatchedAt: movieItem.last_watched_at, + }); + + if (isCompleted) { + upsertItem({ + id: imdb, + type: 'movie', + videoId: undefined, + progressSeconds: 0, + durationSeconds: 0, + progressRatio: 1, // Completed = 100% + lastWatchedAt: watchedAtMs, + }); + updated += 1; + } + } + + // Process TV shows + const shows = data.shows ?? []; + debug('applyAllItemsShows', { count: shows.length }); + + for (const showItem of shows) { + const imdb = showItem.show?.ids?.imdb; + if (!imdb) { + debug('applyAllItemsShowSkip', { reason: 'no-imdb', title: showItem.show?.title }); + continue; + } + + // Process episodes from seasons + const seasons = showItem.seasons ?? []; + for (const season of seasons) { + const seasonNum = season.number; + const episodes = season.episodes ?? []; + + for (const ep of episodes) { + const episodeNum = ep.number; + const videoId = buildStremioEpisodeVideoId(imdb, seasonNum, episodeNum); + const watchedAtMs = ep.watched_at + ? new Date(ep.watched_at).getTime() + : showItem.last_watched_at + ? new Date(showItem.last_watched_at).getTime() + : Date.now(); + + debug('applyAllItemsEpisode', { + imdb, + season: seasonNum, + episode: episodeNum, + watchedAt: ep.watched_at, + }); + + upsertItem({ + id: imdb, + type: 'series', + videoId, + progressSeconds: 0, + durationSeconds: 0, + progressRatio: 1, // Watched = 100% + lastWatchedAt: watchedAtMs, + }); + updated += 1; + } + } + } + + // Process anime (same structure as shows) + const anime = data.anime ?? []; + debug('applyAllItemsAnime', { count: anime.length }); + + for (const animeItem of anime) { + // Anime can have either 'show' or 'anime' key + const animeData = animeItem.anime ?? animeItem.show; + const imdb = animeData?.ids?.imdb; + if (!imdb) { + debug('applyAllItemsAnimeSkip', { reason: 'no-imdb', title: animeData?.title }); + continue; + } + + const seasons = animeItem.seasons ?? []; + for (const season of seasons) { + const seasonNum = season.number; + const episodes = season.episodes ?? []; + + for (const ep of episodes) { + const episodeNum = ep.number; + const videoId = buildStremioEpisodeVideoId(imdb, seasonNum, episodeNum); + const watchedAtMs = ep.watched_at + ? new Date(ep.watched_at).getTime() + : animeItem.last_watched_at + ? new Date(animeItem.last_watched_at).getTime() + : Date.now(); + + debug('applyAllItemsAnimeEpisode', { + imdb, + season: seasonNum, + episode: episodeNum, + watchedAt: ep.watched_at, + }); + + upsertItem({ + id: imdb, + type: 'series', + videoId, + progressSeconds: 0, + durationSeconds: 0, + progressRatio: 1, + lastWatchedAt: watchedAtMs, + }); + updated += 1; + } + } + } + + debug('applyAllItemsComplete', { updated }); + return updated; +}; + +export const performTrackingSync = async (params?: { + reason?: 'manual' | 'interval' | 'app-active'; + showToast?: boolean; +}): Promise<{ pulled: number }> => { + const reason = params?.reason ?? 'manual'; + const showToast = params?.showToast ?? reason === 'manual'; + + debug('performSync', { reason, showToast }); + + if (isSyncing) { + debug('syncSkipped', { reason: 'already-syncing', trigger: reason }); + return { pulled: 0 }; + } + + const tracking = useTrackingStore.getState().getActiveTracking(); + if (!tracking.enabled || tracking.provider !== 'simkl') { + debug('syncSkipped', { reason: 'tracking-disabled', trigger: reason }); + return { pulled: 0 }; + } + + if (!tracking.autoSyncEnabled && reason !== 'manual') { + debug('syncSkipped', { reason: 'auto-sync-disabled', trigger: reason }); + return { pulled: 0 }; + } + + const token = useSimklStore.getState().getAccessToken(); + if (!token) { + debug('syncSkipped', { reason: 'no-token', trigger: reason }); + return { pulled: 0 }; + } + + isSyncing = true; + useTrackingStore.getState().setSyncStatus('syncing'); + debug('syncStarted', { reason }); + + try { + // Step 1: Get activities to know what has changed + const activities = await simklGetActivities(token); + useSimklStore.getState().setLastActivitiesAt(Date.now()); + debug('activitiesFetched', { + all: activities.all, + moviesAll: activities.movies?.all, + tvShowsAll: activities.tv_shows?.all, + animeAll: activities.anime?.all, + }); + + // Step 2: Determine if this is first sync or incremental + const lastSyncAt = tracking.lastSyncAt; + const isFirstSync = !lastSyncAt; + + debug('syncStrategy', { + isFirstSync, + lastSyncAt: lastSyncAt ? new Date(lastSyncAt).toISOString() : null, + }); + + let totalPulled = 0; + + // Step 3: Fetch watch history from /sync/all-items + // According to Simkl docs: + // - First sync: fetch ALL items without date_from + // - Subsequent syncs: use date_from from saved timestamp + const allItemsDateFrom = isFirstSync + ? undefined + : new Date(lastSyncAt).toISOString(); + + debug('fetchingAllItems', { + isFirstSync, + dateFrom: allItemsDateFrom ?? 'none (full sync)', + }); + + const allItems = await simklGetAllItems({ + token, + dateFromIso: allItemsDateFrom, + extended: true, // Get episode details + }); + + const historyPulled = await applySimklAllItemsToWatchHistory(allItems); + totalPulled += historyPulled; + + debug('allItemsSynced', { + historyPulled, + isNull: allItems === null, + showCount: allItems?.shows?.length ?? 0, + animeCount: allItems?.anime?.length ?? 0, + movieCount: allItems?.movies?.length ?? 0, + }); + + // Step 4: Also sync playback sessions for in-progress items (paused/stopped < 80%) + // This is separate from watch history - these are resumable sessions + const playbackDateFrom = isFirstSync + ? undefined + : new Date(lastSyncAt).toISOString(); + + debug('fetchingPlayback', { dateFrom: playbackDateFrom ?? 'none' }); + + const playback = await simklGetPlaybackSessions({ token, dateFromIso: playbackDateFrom }); + const playbackPulled = await applySimklPlaybackToWatchHistory(playback); + totalPulled += playbackPulled; + + debug('playbackSynced', { playbackPulled, playbackCount: playback.length }); + + // Step 5: Save sync timestamp + useTrackingStore.getState().setLastSyncAt(Date.now()); + useTrackingStore.getState().setSyncStatus('idle'); + + debug('syncComplete', { + trigger: reason, + totalPulled, + historyPulled, + playbackPulled, + isFirstSync, + }); + + if (showToast) { + Burnt.toast({ + title: 'Sync complete', + message: totalPulled > 0 ? `Updated ${totalPulled} item(s).` : 'No updates found.', + preset: 'done', + duration: TOAST_DURATION_MEDIUM, + }); + } + + return { pulled: totalPulled }; + } catch (error: any) { + const message = typeof error?.message === 'string' ? error.message : 'Sync failed'; + useTrackingStore.getState().setSyncStatus('error', message); + debug('syncFailed', { trigger: reason, error: message }); + + if (showToast) { + Burnt.toast({ + title: 'Sync failed', + message, + preset: 'error', + duration: TOAST_DURATION_MEDIUM, + }); + } + + return { pulled: 0 }; + } finally { + isSyncing = false; + } +}; + +export const initializeTrackingSync = (): (() => void) => { + debug('initialize'); + + if (!intervalId) { + intervalId = setInterval(() => { + performTrackingSync({ reason: 'interval', showToast: false }); + }, SIMKL_SYNC_INTERVAL_MS); + } + + if (!appStateSubscription) { + lastAppState = AppState.currentState; + appStateSubscription = AppState.addEventListener('change', (nextState) => { + const prev = lastAppState; + lastAppState = nextState; + if (prev !== 'active' && nextState === 'active') { + performTrackingSync({ reason: 'app-active', showToast: false }); + } + }); + } + + return () => { + debug('destroy'); + if (intervalId) { + clearInterval(intervalId); + intervalId = undefined; + } + appStateSubscription?.remove(); + appStateSubscription = undefined; + }; +}; diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index 53a1f63..c4a9b2e 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -15,6 +15,7 @@ import { useContinueWatching, ContinueWatchingEntry } from '@/hooks/useContinueW import { CONTINUE_WATCHING_PAGE_SIZE } from '@/constants/media'; import { useMediaNavigation } from '@/hooks/useMediaNavigation'; import { ContinueWatchingItem } from '@/components/media/ContinueWatchingItem'; +import { TrackingSyncIndicator } from '@/components/tracking/TrackingSyncIndicator'; interface CatalogSectionData { manifestUrl: string; @@ -249,6 +250,8 @@ const HomeSectionHeader = memo(({ section }: HomeSectionHeaderProps) => ( )} + + {section.key === 'continue-watching' ? : null} )); diff --git a/src/app/(tabs)/settings/index.tsx b/src/app/(tabs)/settings/index.tsx index 17c78b9..1e7fcca 100644 --- a/src/app/(tabs)/settings/index.tsx +++ b/src/app/(tabs)/settings/index.tsx @@ -12,6 +12,7 @@ import { SETTINGS_MENU_ITEMS } from '@/constants/settings'; import { PlaybackSettingsContent } from '@/components/settings/PlaybackSettingsContent'; import { ProfilesSettingsContent } from '@/components/settings/ProfilesSettingsContent'; import { AddonsSettingsContent } from '@/components/settings/AddonsSettingsContent'; +import { IntegrationsSettingsContent } from '@/components/settings/IntegrationsSettingsContent'; export default function Settings() { const profiles = useProfileStore((state) => state.profiles); @@ -39,6 +40,8 @@ export default function Settings() { return ; case 'addons': return ; + case 'integrations': + return ; default: return ; } diff --git a/src/app/(tabs)/settings/integrations.tsx b/src/app/(tabs)/settings/integrations.tsx new file mode 100644 index 0000000..bdc5e3c --- /dev/null +++ b/src/app/(tabs)/settings/integrations.tsx @@ -0,0 +1,10 @@ +import { Container } from '@/components/basic/Container'; +import { IntegrationsSettingsContent } from '@/components/settings/IntegrationsSettingsContent'; + +export default function IntegrationsSettings() { + return ( + + + + ); +} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 6e9ad58..7b54c67 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -20,6 +20,8 @@ import { queryClient } from '@/utils/query'; import { initializeAddons, useAddonStore } from '@/store/addon.store'; import { initializeProfiles, useProfileStore } from '@/store/profile.store'; import { ProfileSelector } from '@/components/profile/ProfileSelector'; +import { initializeSimklScrobbleMiddleware } from '@/api/tracking/scrobble'; +import { initializeTrackingSync } from '@/api/tracking/sync'; SplashScreen.preventAutoHideAsync(); @@ -64,6 +66,16 @@ export default function Layout() { } }, [fontsLoaded, isAddonsInitialized, isProfilesInitialized]); + useEffect(() => { + if (!fontsLoaded || !isAddonsInitialized || !isProfilesInitialized) return; + return initializeSimklScrobbleMiddleware(); + }, [fontsLoaded, isAddonsInitialized, isProfilesInitialized]); + + useEffect(() => { + if (!fontsLoaded || !isAddonsInitialized || !isProfilesInitialized) return; + return initializeTrackingSync(); + }, [fontsLoaded, isAddonsInitialized, isProfilesInitialized]); + const handleProfileSelect = () => { router.replace('/'); }; diff --git a/src/components/settings/IntegrationsSettingsContent.tsx b/src/components/settings/IntegrationsSettingsContent.tsx new file mode 100644 index 0000000..6c513d7 --- /dev/null +++ b/src/components/settings/IntegrationsSettingsContent.tsx @@ -0,0 +1,306 @@ +import { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ScrollView } from 'react-native-gesture-handler'; +import * as Burnt from 'burnt'; +import * as WebBrowser from 'expo-web-browser'; +import { useTheme } from '@shopify/restyle'; +import type { Theme } from '@/theme/theme'; +import { Box, Text } from '@/theme/theme'; +import { SettingsCard } from '@/components/settings/SettingsCard'; +import { SettingsRow } from '@/components/settings/SettingsRow'; +import { SettingsSwitch } from '@/components/settings/SettingsSwitch'; +import { Button } from '@/components/basic/Button'; +import { simklCreatePin, simklPollPin } from '@/api/simkl/auth'; +import { SIMKL_PIN_POLL_TICK_MS } from '@/constants/tracking'; +import { useSimklStore } from '@/store/simkl.store'; +import { useTrackingStore } from '@/store/tracking.store'; +import { createDebugLogger } from '@/utils/debug'; +import { performTrackingSync } from '@/api/tracking/sync'; +import { TOAST_DURATION_MEDIUM } from '@/constants/ui'; + +const debug = createDebugLogger('IntegrationsSettings'); + +export const IntegrationsSettingsContent: FC = memo(() => { + const theme = useTheme(); + const activeTracking = useTrackingStore((state) => state.getActiveTracking()); + const setEnabled = useTrackingStore((state) => state.setEnabled); + const setAutoSyncEnabled = useTrackingStore((state) => state.setAutoSyncEnabled); + const resetTracking = useTrackingStore((state) => state.reset); + + const simkl = useSimklStore((state) => state.getActiveSimkl()); + const getAccessToken = useSimklStore((state) => state.getAccessToken); + const setAuthStatus = useSimklStore((state) => state.setAuthStatus); + const setAccessToken = useSimklStore((state) => state.setAccessToken); + const setPin = useSimklStore((state) => state.setPin); + const clearAuth = useSimklStore((state) => state.clearAuth); + const isScrobblingEnabled = useSimklStore((state) => state.isScrobblingEnabled); + const setScrobblingEnabled = useSimklStore((state) => state.setScrobblingEnabled); + + const isConnected = useMemo(() => !!getAccessToken(), [getAccessToken]); + const [isStartingPin, setIsStartingPin] = useState(false); + const pollingRef = useRef | undefined>(undefined); + + const stopPolling = useCallback(() => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = undefined; + } + }, []); + + useEffect(() => { + return () => { + stopPolling(); + }; + }, [stopPolling]); + + const handleOpenSimkl = useCallback(async () => { + const url = simkl.pin?.verificationUrl ?? 'https://simkl.com/pin/'; + try { + await WebBrowser.openBrowserAsync(url); + } catch (error) { + debug('openBrowserFailed', { url, error }); + Burnt.toast({ + title: 'Failed to open browser', + message: 'Please open Simkl in your browser and enter the code.', + preset: 'error', + duration: TOAST_DURATION_MEDIUM, + }); + } + }, [simkl.pin?.verificationUrl]); + + const startPolling = useCallback( + (userCode: string) => { + debug('startPolling', { userCode }); + stopPolling(); + + pollingRef.current = setInterval(async () => { + try { + const result = await simklPollPin(userCode); + const token = result.access_token; + if (!token) return; + + debug('pinConnected'); + stopPolling(); + setPin(undefined); + setAccessToken(token); + setAuthStatus('connected'); + Burnt.toast({ + title: 'Simkl connected', + preset: 'done', + duration: TOAST_DURATION_MEDIUM, + }); + } catch (error) { + debug('pollFailed', { error }); + } + }, SIMKL_PIN_POLL_TICK_MS); + }, + [setAccessToken, setAuthStatus, setPin, stopPolling] + ); + + const handleConnectSimkl = useCallback(async () => { + if (isStartingPin) return; + debug('handleConnectSimkl', { action: 'start' }); + setIsStartingPin(true); + stopPolling(); + + try { + setAuthStatus('connecting'); + const pin = await simklCreatePin(); + const userCode = pin.user_code; + debug('handleConnectSimkl', { action: 'pinReceived', userCode, expiresIn: pin.expires_in }); + if (!userCode) { + setAuthStatus('error', pin.message ?? 'Failed to start PIN flow'); + return; + } + + const createdAt = Date.now(); + const expiresAt = pin.expires_in ? createdAt + pin.expires_in * 1000 : undefined; + + setPin({ + userCode, + verificationUrl: pin.verification_url ?? 'https://simkl.com/pin/', + createdAt, + expiresAt, + }); + + Burnt.toast({ + title: 'Enter code on Simkl', + message: userCode, + preset: 'none', + duration: TOAST_DURATION_MEDIUM, + }); + + startPolling(userCode); + } catch (error: any) { + debug('connectFailed', { error }); + setAuthStatus('error', typeof error?.message === 'string' ? error.message : 'Connect failed'); + Burnt.toast({ + title: 'Simkl connect failed', + message: typeof error?.message === 'string' ? error.message : 'Unknown error', + preset: 'error', + duration: TOAST_DURATION_MEDIUM, + }); + } finally { + setIsStartingPin(false); + } + }, [isStartingPin, setAuthStatus, setPin, startPolling, stopPolling]); + + const handleDisconnect = useCallback(() => { + debug('handleDisconnect'); + stopPolling(); + clearAuth(); + resetTracking(); // Clear tracking data for fresh start on reconnect + Burnt.toast({ + title: 'Simkl disconnected', + preset: 'done', + duration: TOAST_DURATION_MEDIUM, + }); + }, [clearAuth, resetTracking, stopPolling]); + + const handleSyncNow = useCallback(async () => { + debug('handleSyncNow'); + await performTrackingSync({ reason: 'manual', showToast: true }); + }, []); + + const syncStatusLabel = useMemo(() => { + if (activeTracking.syncStatus === 'syncing') return 'Syncing…'; + if (activeTracking.syncStatus === 'error') return 'Error'; + return 'Idle'; + }, [activeTracking.syncStatus]); + + const statusDotSize = theme.spacing.s; + + return ( + + + + + + + + + + {syncStatusLabel} + + + + + +