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