mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-26 19:12:54 +00:00
608 lines
No EOL
21 KiB
TypeScript
608 lines
No EOL
21 KiB
TypeScript
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<string, boolean>;
|
|
}
|
|
|
|
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<any>> = [];
|
|
private isProcessingQueue: boolean = false;
|
|
|
|
// Track scrobbled items to prevent duplicates/spam
|
|
private lastSyncTimes: Map<string, number> = 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<void> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
await this.ensureInitialized();
|
|
this.accessToken = null;
|
|
await mmkvStorage.removeItem(SIMKL_ACCESS_TOKEN_KEY);
|
|
logger.log('[SimklService] Logged out');
|
|
}
|
|
|
|
private async ensureInitialized(): Promise<void> {
|
|
if (!this.isInitialized) {
|
|
await this.initialize();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Base API Request handler
|
|
*/
|
|
private async apiRequest<T>(
|
|
endpoint: string,
|
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
|
body?: any
|
|
): Promise<T | null> {
|
|
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<SimklScrobbleResponse | null> {
|
|
try {
|
|
const payload = this.buildScrobblePayload(content, progress);
|
|
logger.log('[SimklService] scrobbleStart payload:', JSON.stringify(payload));
|
|
const response = await this.apiRequest<SimklScrobbleResponse>('/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<SimklScrobbleResponse | null> {
|
|
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<SimklScrobbleResponse>('/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<SimklScrobbleResponse | null> {
|
|
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<SimklScrobbleResponse>('/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<any> {
|
|
return await this.apiRequest('/sync/history', 'POST', items);
|
|
}
|
|
|
|
/**
|
|
* SYNC: Remove items from History
|
|
*/
|
|
public async removeFromHistory(items: { movies?: any[], shows?: any[], episodes?: any[] }): Promise<any> {
|
|
return await this.apiRequest('/sync/history/remove', 'POST', items);
|
|
}
|
|
|
|
public async getPlaybackStatus(): Promise<SimklPlaybackData[]> {
|
|
// 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<SimklPlaybackData[]> => {
|
|
for (const endpoint of endpoints) {
|
|
try {
|
|
const res = await this.apiRequest<SimklPlaybackData[]>(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<any> {
|
|
let url = '/sync/all-items/';
|
|
if (dateFrom) {
|
|
url += `?date_from=${dateFrom}`;
|
|
}
|
|
return await this.apiRequest(url);
|
|
}
|
|
|
|
/**
|
|
* Get user settings/profile
|
|
*/
|
|
public async getUserSettings(): Promise<SimklUserSettings | null> {
|
|
try {
|
|
const response = await this.apiRequest<SimklUserSettings>('/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<SimklStats | null> {
|
|
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<SimklStats>(`/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;
|
|
}
|
|
}} |