mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
2517 lines
No EOL
73 KiB
TypeScript
2517 lines
No EOL
73 KiB
TypeScript
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||
import { AppState, AppStateStatus } from 'react-native';
|
||
import { logger } from '../utils/logger';
|
||
import { imageCacheService } from './imageCacheService';
|
||
|
||
// Storage keys
|
||
export const TRAKT_ACCESS_TOKEN_KEY = 'trakt_access_token';
|
||
export const TRAKT_REFRESH_TOKEN_KEY = 'trakt_refresh_token';
|
||
export const TRAKT_TOKEN_EXPIRY_KEY = 'trakt_token_expiry';
|
||
|
||
// Trakt API configuration
|
||
const TRAKT_API_URL = 'https://api.trakt.tv';
|
||
const TRAKT_CLIENT_ID = process.env.EXPO_PUBLIC_TRAKT_CLIENT_ID as string;
|
||
const TRAKT_CLIENT_SECRET = process.env.EXPO_PUBLIC_TRAKT_CLIENT_SECRET as string;
|
||
const TRAKT_REDIRECT_URI = process.env.EXPO_PUBLIC_TRAKT_REDIRECT_URI || 'nuvio://auth/trakt'; // Must match registered callback URL
|
||
|
||
if (!TRAKT_CLIENT_ID || !TRAKT_CLIENT_SECRET) {
|
||
throw new Error('Missing Trakt env vars. Set EXPO_PUBLIC_TRAKT_CLIENT_ID and EXPO_PUBLIC_TRAKT_CLIENT_SECRET');
|
||
}
|
||
|
||
// Types
|
||
export interface TraktUser {
|
||
username: string;
|
||
name?: string;
|
||
private: boolean;
|
||
vip: boolean;
|
||
joined_at: string;
|
||
avatar?: string;
|
||
}
|
||
|
||
export interface TraktWatchedItem {
|
||
movie?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
images?: TraktImages;
|
||
};
|
||
show?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
images?: TraktImages;
|
||
};
|
||
plays: number;
|
||
last_watched_at: string;
|
||
}
|
||
|
||
export interface TraktWatchlistItem {
|
||
movie?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
images?: TraktImages;
|
||
};
|
||
show?: {
|
||
title: string;
|
||
year: number
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
images?: TraktImages;
|
||
};
|
||
listed_at: string;
|
||
}
|
||
|
||
export interface TraktCollectionItem {
|
||
movie?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
images?: TraktImages;
|
||
};
|
||
show?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
images?: TraktImages;
|
||
};
|
||
collected_at: string;
|
||
}
|
||
|
||
export interface TraktRatingItem {
|
||
movie?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
images?: TraktImages;
|
||
};
|
||
show?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
images?: TraktImages;
|
||
};
|
||
rating: number;
|
||
rated_at: string;
|
||
}
|
||
|
||
export interface TraktImages {
|
||
fanart?: string[];
|
||
poster?: string[];
|
||
logo?: string[];
|
||
clearart?: string[];
|
||
banner?: string[];
|
||
thumb?: string[];
|
||
}
|
||
|
||
export interface TraktItemWithImages {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
images?: TraktImages;
|
||
}
|
||
|
||
// New types for scrobbling
|
||
export interface TraktPlaybackItem {
|
||
progress: number;
|
||
paused_at: string;
|
||
id: number;
|
||
type: 'movie' | 'episode';
|
||
movie?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
images?: TraktImages;
|
||
};
|
||
episode?: {
|
||
season: number;
|
||
number: number;
|
||
title: string;
|
||
ids: {
|
||
trakt: number;
|
||
tvdb?: number;
|
||
imdb?: string;
|
||
tmdb?: number;
|
||
};
|
||
images?: TraktImages;
|
||
};
|
||
show?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
tvdb?: number;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
images?: TraktImages;
|
||
};
|
||
}
|
||
|
||
export interface TraktScrobbleResponse {
|
||
id: number;
|
||
action: 'start' | 'pause' | 'scrobble' | 'conflict';
|
||
progress: number;
|
||
sharing?: {
|
||
twitter?: boolean;
|
||
mastodon?: boolean;
|
||
tumblr?: boolean;
|
||
facebook?: boolean;
|
||
};
|
||
movie?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
};
|
||
episode?: {
|
||
season: number;
|
||
number: number;
|
||
title: string;
|
||
ids: {
|
||
trakt: number;
|
||
tvdb?: number;
|
||
imdb?: string;
|
||
tmdb?: number;
|
||
};
|
||
};
|
||
show?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
tvdb?: number;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
};
|
||
// Additional field for 409 handling
|
||
alreadyScrobbled?: boolean;
|
||
}
|
||
|
||
export interface TraktContentData {
|
||
type: 'movie' | 'episode';
|
||
imdbId: string;
|
||
title: string;
|
||
year: number;
|
||
season?: number;
|
||
episode?: number;
|
||
showTitle?: string;
|
||
showYear?: number;
|
||
showImdbId?: string;
|
||
}
|
||
|
||
export interface TraktHistoryItem {
|
||
id: number;
|
||
watched_at: string;
|
||
action: 'scrobble' | 'checkin' | 'watch';
|
||
type: 'movie' | 'episode';
|
||
movie?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
};
|
||
episode?: {
|
||
season: number;
|
||
number: number;
|
||
title: string;
|
||
ids: {
|
||
trakt: number;
|
||
tvdb?: number;
|
||
imdb?: string;
|
||
tmdb?: number;
|
||
};
|
||
};
|
||
show?: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
tvdb?: number;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
};
|
||
}
|
||
|
||
export interface TraktHistoryRemovePayload {
|
||
movies?: Array<{
|
||
title?: string;
|
||
year?: number;
|
||
ids: {
|
||
trakt?: number;
|
||
slug?: string;
|
||
imdb?: string;
|
||
tmdb?: number;
|
||
};
|
||
}>;
|
||
shows?: Array<{
|
||
title?: string;
|
||
year?: number;
|
||
ids: {
|
||
trakt?: number;
|
||
slug?: string;
|
||
tvdb?: number;
|
||
imdb?: string;
|
||
tmdb?: number;
|
||
};
|
||
seasons?: Array<{
|
||
number: number;
|
||
episodes?: Array<{
|
||
number: number;
|
||
}>;
|
||
}>;
|
||
}>;
|
||
seasons?: Array<{
|
||
ids: {
|
||
trakt?: number;
|
||
tvdb?: number;
|
||
tmdb?: number;
|
||
};
|
||
}>;
|
||
episodes?: Array<{
|
||
ids: {
|
||
trakt?: number;
|
||
tvdb?: number;
|
||
imdb?: string;
|
||
tmdb?: number;
|
||
};
|
||
}>;
|
||
ids?: number[];
|
||
}
|
||
|
||
export interface TraktHistoryRemoveResponse {
|
||
deleted: {
|
||
movies: number;
|
||
episodes: number;
|
||
shows?: number;
|
||
seasons?: number;
|
||
};
|
||
not_found: {
|
||
movies: Array<{
|
||
ids: {
|
||
imdb?: string;
|
||
trakt?: number;
|
||
tmdb?: number;
|
||
};
|
||
}>;
|
||
shows: Array<{
|
||
ids: {
|
||
imdb?: string;
|
||
trakt?: number;
|
||
tvdb?: number;
|
||
tmdb?: number;
|
||
};
|
||
}>;
|
||
seasons: Array<{
|
||
ids: {
|
||
trakt?: number;
|
||
tvdb?: number;
|
||
tmdb?: number;
|
||
};
|
||
}>;
|
||
episodes: Array<{
|
||
ids: {
|
||
trakt?: number;
|
||
tvdb?: number;
|
||
imdb?: string;
|
||
tmdb?: number;
|
||
};
|
||
}>;
|
||
ids: number[];
|
||
};
|
||
}
|
||
|
||
// Comment types
|
||
export interface TraktComment {
|
||
id: number;
|
||
comment: string;
|
||
spoiler: boolean;
|
||
review: boolean;
|
||
parent_id: number;
|
||
created_at: string;
|
||
updated_at: string;
|
||
replies: number;
|
||
likes: number;
|
||
user_stats?: {
|
||
rating?: number | null;
|
||
play_count?: number;
|
||
completed_count?: number;
|
||
};
|
||
user: {
|
||
username: string;
|
||
private: boolean;
|
||
name?: string;
|
||
vip: boolean;
|
||
vip_ep: boolean;
|
||
ids: {
|
||
slug: string;
|
||
};
|
||
};
|
||
}
|
||
|
||
export interface TraktMovieComment {
|
||
type: 'movie';
|
||
movie: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
};
|
||
comment: TraktComment;
|
||
}
|
||
|
||
export interface TraktShowComment {
|
||
type: 'show';
|
||
show: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
tvdb?: number;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
};
|
||
comment: TraktComment;
|
||
}
|
||
|
||
export interface TraktSeasonComment {
|
||
type: 'season';
|
||
season: {
|
||
number: number;
|
||
ids: {
|
||
trakt: number;
|
||
tvdb?: number;
|
||
tmdb?: number;
|
||
};
|
||
};
|
||
show: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
tvdb?: number;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
};
|
||
comment: TraktComment;
|
||
}
|
||
|
||
export interface TraktEpisodeComment {
|
||
type: 'episode';
|
||
episode: {
|
||
season: number;
|
||
number: number;
|
||
title: string;
|
||
ids: {
|
||
trakt: number;
|
||
tvdb?: number;
|
||
imdb?: string;
|
||
tmdb?: number;
|
||
};
|
||
};
|
||
show: {
|
||
title: string;
|
||
year: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
tvdb?: number;
|
||
imdb: string;
|
||
tmdb: number;
|
||
};
|
||
};
|
||
comment: TraktComment;
|
||
}
|
||
|
||
export interface TraktListComment {
|
||
type: 'list';
|
||
list: {
|
||
name: string;
|
||
description?: string;
|
||
privacy: string;
|
||
share_link?: string;
|
||
display_numbers: boolean;
|
||
allow_comments: boolean;
|
||
updated_at: string;
|
||
item_count: number;
|
||
comment_count: number;
|
||
likes: number;
|
||
ids: {
|
||
trakt: number;
|
||
slug: string;
|
||
};
|
||
};
|
||
comment: TraktComment;
|
||
}
|
||
|
||
// Simplified comment type based on actual API response
|
||
export interface TraktContentComment {
|
||
id: number;
|
||
comment: string;
|
||
spoiler: boolean;
|
||
review: boolean;
|
||
parent_id: number;
|
||
created_at: string;
|
||
updated_at: string;
|
||
replies: number;
|
||
likes: number;
|
||
language: string;
|
||
user_rating?: number;
|
||
user_stats?: {
|
||
rating?: number;
|
||
play_count?: number;
|
||
completed_count?: number;
|
||
};
|
||
user: {
|
||
username: string;
|
||
private: boolean;
|
||
deleted?: boolean;
|
||
name?: string;
|
||
vip: boolean;
|
||
vip_ep: boolean;
|
||
director?: boolean;
|
||
ids: {
|
||
slug: string;
|
||
};
|
||
};
|
||
}
|
||
|
||
// Keep the old types for backward compatibility if needed
|
||
export type TraktContentCommentLegacy =
|
||
| TraktMovieComment
|
||
| TraktShowComment
|
||
| TraktSeasonComment
|
||
| TraktEpisodeComment
|
||
| TraktListComment;
|
||
|
||
export class TraktService {
|
||
private static instance: TraktService;
|
||
private accessToken: string | null = null;
|
||
private refreshToken: string | null = null;
|
||
private tokenExpiry: number = 0;
|
||
private isInitialized: boolean = false;
|
||
|
||
// Rate limiting
|
||
private lastApiCall: number = 0;
|
||
private readonly MIN_API_INTERVAL = 3000; // Minimum 3 seconds between API calls (further reduce heating)
|
||
private requestQueue: Array<() => Promise<any>> = [];
|
||
private isProcessingQueue: boolean = false;
|
||
|
||
// Track items that have been successfully scrobbled to prevent duplicates
|
||
private scrobbledItems: Set<string> = new Set();
|
||
private readonly SCROBBLE_EXPIRY_MS = 46 * 60 * 1000; // 46 minutes (based on Trakt's expiry window)
|
||
private scrobbledTimestamps: Map<string, number> = new Map();
|
||
|
||
// Track currently watching sessions to avoid duplicate starts// Sync debouncing
|
||
private currentlyWatching: Set<string> = new Set();
|
||
private lastSyncTimes: Map<string, number> = new Map();
|
||
private readonly SYNC_DEBOUNCE_MS = 20000; // 20 seconds to further reduce API calls
|
||
|
||
// Debounce for stop calls
|
||
private lastStopCalls: Map<string, number> = new Map();
|
||
private readonly STOP_DEBOUNCE_MS = 3000; // 3 seconds to avoid duplicate stop calls
|
||
|
||
// Default completion threshold (overridden by user settings)
|
||
private readonly DEFAULT_COMPLETION_THRESHOLD = 80; // 80%
|
||
|
||
private constructor() {
|
||
// Increased cleanup interval from 5 minutes to 15 minutes to reduce heating
|
||
setInterval(() => this.cleanupOldStopCalls(), 15 * 60 * 1000); // Clean up every 15 minutes
|
||
|
||
// Add AppState cleanup to reduce memory pressure
|
||
AppState.addEventListener('change', this.handleAppStateChange);
|
||
|
||
// Load user settings
|
||
this.loadCompletionThreshold();
|
||
}
|
||
|
||
/**
|
||
* Load user-configured completion threshold from AsyncStorage
|
||
*/
|
||
private async loadCompletionThreshold(): Promise<void> {
|
||
try {
|
||
const thresholdStr = await AsyncStorage.getItem('@trakt_completion_threshold');
|
||
if (thresholdStr) {
|
||
const threshold = parseInt(thresholdStr, 10);
|
||
if (!isNaN(threshold) && threshold >= 50 && threshold <= 100) {
|
||
logger.log(`[TraktService] Loaded user completion threshold: ${threshold}%`);
|
||
this.completionThreshold = threshold;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error('[TraktService] Error loading completion threshold:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get the current completion threshold (user-configured or default)
|
||
*/
|
||
private get completionThreshold(): number {
|
||
return this._completionThreshold || this.DEFAULT_COMPLETION_THRESHOLD;
|
||
}
|
||
|
||
/**
|
||
* Set the completion threshold
|
||
*/
|
||
private set completionThreshold(value: number) {
|
||
this._completionThreshold = value;
|
||
}
|
||
|
||
// Backing field for completion threshold
|
||
private _completionThreshold: number | null = null;
|
||
|
||
/**
|
||
* Clean up old stop call records to prevent memory leaks
|
||
*/
|
||
private cleanupOldStopCalls(): void {
|
||
const now = Date.now();
|
||
let cleanupCount = 0;
|
||
|
||
// Remove stop calls older than the debounce window
|
||
for (const [key, timestamp] of this.lastStopCalls.entries()) {
|
||
if (now - timestamp > this.STOP_DEBOUNCE_MS) {
|
||
this.lastStopCalls.delete(key);
|
||
cleanupCount++;
|
||
}
|
||
}
|
||
|
||
// Also clean up old scrobbled timestamps
|
||
for (const [key, timestamp] of this.scrobbledTimestamps.entries()) {
|
||
if (now - timestamp > this.SCROBBLE_EXPIRY_MS) {
|
||
this.scrobbledTimestamps.delete(key);
|
||
this.scrobbledItems.delete(key);
|
||
cleanupCount++;
|
||
}
|
||
}
|
||
|
||
// Clean up old sync times that haven't been updated in a while
|
||
for (const [key, timestamp] of this.lastSyncTimes.entries()) {
|
||
if (now - timestamp > 24 * 60 * 60 * 1000) { // 24 hours
|
||
this.lastSyncTimes.delete(key);
|
||
cleanupCount++;
|
||
}
|
||
}
|
||
|
||
// Skip verbose cleanup logging to reduce CPU load
|
||
}
|
||
|
||
public static getInstance(): TraktService {
|
||
if (!TraktService.instance) {
|
||
TraktService.instance = new TraktService();
|
||
}
|
||
return TraktService.instance;
|
||
}
|
||
|
||
/**
|
||
* Initialize the Trakt service by loading stored tokens
|
||
*/
|
||
public async initialize(): Promise<void> {
|
||
if (this.isInitialized) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const [accessToken, refreshToken, tokenExpiry] = await Promise.all([
|
||
AsyncStorage.getItem(TRAKT_ACCESS_TOKEN_KEY),
|
||
AsyncStorage.getItem(TRAKT_REFRESH_TOKEN_KEY),
|
||
AsyncStorage.getItem(TRAKT_TOKEN_EXPIRY_KEY)
|
||
]);
|
||
|
||
this.accessToken = accessToken;
|
||
this.refreshToken = refreshToken;
|
||
this.tokenExpiry = tokenExpiry ? parseInt(tokenExpiry, 10) : 0;
|
||
this.isInitialized = true;
|
||
|
||
logger.log('[TraktService] Initialized, authenticated:', !!this.accessToken);
|
||
} catch (error) {
|
||
logger.error('[TraktService] Initialization failed:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if the user is authenticated with Trakt
|
||
*/
|
||
public async isAuthenticated(): Promise<boolean> {
|
||
await this.ensureInitialized();
|
||
|
||
if (!this.accessToken) {
|
||
return false;
|
||
}
|
||
|
||
// Check if token is expired and needs refresh
|
||
if (this.tokenExpiry && this.tokenExpiry < Date.now() && this.refreshToken) {
|
||
try {
|
||
await this.refreshAccessToken();
|
||
return !!this.accessToken;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Get the authentication URL for Trakt OAuth
|
||
*/
|
||
public getAuthUrl(): string {
|
||
return `https://trakt.tv/oauth/authorize?response_type=code&client_id=${TRAKT_CLIENT_ID}&redirect_uri=${encodeURIComponent(TRAKT_REDIRECT_URI)}`;
|
||
}
|
||
|
||
/**
|
||
* Exchange the authorization code for an access token
|
||
*/
|
||
public async exchangeCodeForToken(code: string, codeVerifier: string): Promise<boolean> {
|
||
await this.ensureInitialized();
|
||
|
||
try {
|
||
const response = await fetch(`${TRAKT_API_URL}/oauth/token`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
code,
|
||
client_id: TRAKT_CLIENT_ID,
|
||
client_secret: TRAKT_CLIENT_SECRET,
|
||
redirect_uri: TRAKT_REDIRECT_URI,
|
||
grant_type: 'authorization_code',
|
||
code_verifier: codeVerifier
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorBody = await response.text();
|
||
logger.error('[TraktService] Token exchange error response:', errorBody);
|
||
throw new Error(`Failed to exchange code: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
await this.saveTokens(data.access_token, data.refresh_token, data.expires_in);
|
||
return true;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to exchange code for token:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Refresh the access token using the refresh token
|
||
*/
|
||
private async refreshAccessToken(): Promise<void> {
|
||
if (!this.refreshToken) {
|
||
throw new Error('No refresh token available');
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${TRAKT_API_URL}/oauth/token`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
refresh_token: this.refreshToken,
|
||
client_id: TRAKT_CLIENT_ID,
|
||
client_secret: TRAKT_CLIENT_SECRET,
|
||
redirect_uri: TRAKT_REDIRECT_URI,
|
||
grant_type: 'refresh_token'
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to refresh token: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
await this.saveTokens(data.access_token, data.refresh_token, data.expires_in);
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to refresh token:', error);
|
||
await this.logout(); // Clear tokens if refresh fails
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save authentication tokens to storage
|
||
*/
|
||
private async saveTokens(accessToken: string, refreshToken: string, expiresIn: number): Promise<void> {
|
||
this.accessToken = accessToken;
|
||
this.refreshToken = refreshToken;
|
||
this.tokenExpiry = Date.now() + (expiresIn * 1000);
|
||
|
||
try {
|
||
await AsyncStorage.multiSet([
|
||
[TRAKT_ACCESS_TOKEN_KEY, accessToken],
|
||
[TRAKT_REFRESH_TOKEN_KEY, refreshToken],
|
||
[TRAKT_TOKEN_EXPIRY_KEY, this.tokenExpiry.toString()]
|
||
]);
|
||
logger.log('[TraktService] Tokens saved successfully');
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to save tokens:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Log out the user by clearing all tokens
|
||
*/
|
||
public async logout(): Promise<void> {
|
||
await this.ensureInitialized();
|
||
|
||
try {
|
||
this.accessToken = null;
|
||
this.refreshToken = null;
|
||
this.tokenExpiry = 0;
|
||
|
||
await AsyncStorage.multiRemove([
|
||
TRAKT_ACCESS_TOKEN_KEY,
|
||
TRAKT_REFRESH_TOKEN_KEY,
|
||
TRAKT_TOKEN_EXPIRY_KEY
|
||
]);
|
||
logger.log('[TraktService] Logged out successfully');
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to logout:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ensure the service is initialized before performing operations
|
||
*/
|
||
private async ensureInitialized(): Promise<void> {
|
||
if (!this.isInitialized) {
|
||
await this.initialize();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Make an authenticated API request to Trakt
|
||
*/
|
||
private async apiRequest<T>(
|
||
endpoint: string,
|
||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||
body?: any,
|
||
retryCount: number = 0
|
||
): Promise<T> {
|
||
await this.ensureInitialized();
|
||
|
||
// Rate limiting: ensure minimum interval between API calls
|
||
const now = Date.now();
|
||
const timeSinceLastCall = now - this.lastApiCall;
|
||
if (timeSinceLastCall < this.MIN_API_INTERVAL) {
|
||
const delay = this.MIN_API_INTERVAL - timeSinceLastCall;
|
||
await new Promise(resolve => setTimeout(resolve, delay));
|
||
}
|
||
this.lastApiCall = Date.now();
|
||
|
||
// Ensure we have a valid token
|
||
if (this.tokenExpiry && this.tokenExpiry < Date.now() && this.refreshToken) {
|
||
await this.refreshAccessToken();
|
||
}
|
||
|
||
if (!this.accessToken) {
|
||
throw new Error('Not authenticated');
|
||
}
|
||
|
||
const headers: HeadersInit = {
|
||
'Content-Type': 'application/json',
|
||
'trakt-api-version': '2',
|
||
'trakt-api-key': TRAKT_CLIENT_ID as string,
|
||
'Authorization': `Bearer ${this.accessToken}`
|
||
};
|
||
|
||
const options: RequestInit = {
|
||
method,
|
||
headers
|
||
};
|
||
|
||
if (body) {
|
||
options.body = JSON.stringify(body);
|
||
}
|
||
|
||
const response = await fetch(`${TRAKT_API_URL}${endpoint}`, options);
|
||
|
||
// Debug logging removed to reduce terminal noise
|
||
|
||
// Handle rate limiting with exponential backoff
|
||
if (response.status === 429) {
|
||
const maxRetries = 3;
|
||
if (retryCount < maxRetries) {
|
||
const retryAfter = response.headers.get('Retry-After');
|
||
const delay = retryAfter
|
||
? parseInt(retryAfter) * 1000
|
||
: Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s
|
||
|
||
logger.log(`[TraktService] Rate limited (429), retrying in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`);
|
||
|
||
await new Promise(resolve => setTimeout(resolve, delay));
|
||
return this.apiRequest<T>(endpoint, method, body, retryCount + 1);
|
||
} else {
|
||
logger.error(`[TraktService] Rate limited (429), max retries exceeded for ${endpoint}`);
|
||
throw new Error(`API request failed: 429 (Rate Limited)`);
|
||
}
|
||
}
|
||
|
||
// Handle 409 conflicts gracefully (already watched/scrobbled)
|
||
if (response.status === 409) {
|
||
const errorText = await response.text();
|
||
logger.log(`[TraktService] Content already scrobbled (409) for ${endpoint}:`, errorText);
|
||
|
||
// Parse the error response to get expiry info
|
||
try {
|
||
const errorData = JSON.parse(errorText);
|
||
if (errorData.watched_at && errorData.expires_at) {
|
||
logger.log(`[TraktService] Item was already watched at ${errorData.watched_at}, expires at ${errorData.expires_at}`);
|
||
|
||
// If this is a scrobble endpoint, mark the item as already scrobbled
|
||
if (endpoint.includes('/scrobble/') && body) {
|
||
const contentKey = this.getContentKeyFromPayload(body);
|
||
if (contentKey) {
|
||
this.scrobbledItems.add(contentKey);
|
||
this.scrobbledTimestamps.set(contentKey, Date.now());
|
||
logger.log(`[TraktService] Marked content as already scrobbled: ${contentKey}`);
|
||
}
|
||
}
|
||
|
||
// Return a success-like response for 409 conflicts
|
||
// This prevents the error from bubbling up and causing retry loops
|
||
return {
|
||
id: 0,
|
||
action: endpoint.includes('/stop') ? 'scrobble' : 'start',
|
||
progress: body?.progress || 0,
|
||
alreadyScrobbled: true
|
||
} as any;
|
||
}
|
||
} catch (parseError) {
|
||
logger.warn(`[TraktService] Could not parse 409 error response: ${parseError}`);
|
||
}
|
||
|
||
// Return a graceful response even if we can't parse the error
|
||
return {
|
||
id: 0,
|
||
action: 'conflict',
|
||
progress: 0,
|
||
alreadyScrobbled: true
|
||
} as any;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
|
||
// Enhanced error logging for debugging
|
||
logger.error(`[TraktService] API Error ${response.status} for ${endpoint}:`, {
|
||
status: response.status,
|
||
statusText: response.statusText,
|
||
errorText: errorText,
|
||
requestBody: body ? JSON.stringify(body, null, 2) : 'No body',
|
||
headers: Object.fromEntries(response.headers.entries())
|
||
});
|
||
|
||
// Handle 404 errors more gracefully - they might indicate content not found in Trakt
|
||
if (response.status === 404) {
|
||
logger.warn(`[TraktService] Content not found in Trakt database (404) for ${endpoint}. This might indicate:`);
|
||
logger.warn(`[TraktService] 1. Invalid IMDb ID: ${body?.movie?.ids?.imdb || body?.show?.ids?.imdb || 'N/A'}`);
|
||
logger.warn(`[TraktService] 2. Content not in Trakt database: ${body?.movie?.title || body?.show?.title || 'N/A'}`);
|
||
logger.warn(`[TraktService] 3. Authentication issues with token`);
|
||
|
||
// Return a graceful response for 404s instead of throwing
|
||
return {
|
||
id: 0,
|
||
action: 'not_found',
|
||
progress: body?.progress || 0,
|
||
error: 'Content not found in Trakt database'
|
||
} as any;
|
||
}
|
||
|
||
throw new Error(`API request failed: ${response.status}`);
|
||
}
|
||
|
||
// Handle "No Content" responses (204/205) which have no JSON body
|
||
if (response.status === 204 || response.status === 205) {
|
||
// Return null casted to expected type to satisfy caller's generic
|
||
return null as unknown as T;
|
||
}
|
||
|
||
// Some endpoints (e.g., DELETE) may also return empty body with 200. Attempt safe parse.
|
||
let responseData: T;
|
||
try {
|
||
responseData = await response.json() as T;
|
||
} catch (parseError) {
|
||
// If body is empty, return null instead of throwing
|
||
logger.warn(`[TraktService] Empty JSON body for ${endpoint}, returning null`);
|
||
return null as unknown as T;
|
||
}
|
||
|
||
// Debug log successful scrobble responses
|
||
if (endpoint.includes('/scrobble/')) {
|
||
// API success logging removed
|
||
}
|
||
|
||
return responseData;
|
||
}
|
||
|
||
/**
|
||
* Helper method to extract content key from scrobble payload for deduplication
|
||
*/
|
||
private getContentKeyFromPayload(payload: any): string | null {
|
||
try {
|
||
if (payload.movie && payload.movie.ids && payload.movie.ids.imdb) {
|
||
return `movie:${payload.movie.ids.imdb}`;
|
||
} else if (payload.episode && payload.show && payload.show.ids && payload.show.ids.imdb) {
|
||
return `episode:${payload.show.ids.imdb}:${payload.episode.season}:${payload.episode.number}`;
|
||
}
|
||
} catch (error) {
|
||
logger.warn('[TraktService] Could not extract content key from payload:', error);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Check if content was recently scrobbled to prevent duplicates
|
||
*/
|
||
private isRecentlyScrobbled(contentData: TraktContentData): boolean {
|
||
const contentKey = this.getWatchingKey(contentData);
|
||
|
||
// Clean up expired entries
|
||
const now = Date.now();
|
||
for (const [key, timestamp] of this.scrobbledTimestamps.entries()) {
|
||
if (now - timestamp > this.SCROBBLE_EXPIRY_MS) {
|
||
this.scrobbledItems.delete(key);
|
||
this.scrobbledTimestamps.delete(key);
|
||
}
|
||
}
|
||
|
||
return this.scrobbledItems.has(contentKey);
|
||
}
|
||
|
||
/**
|
||
* Get the user's profile information
|
||
*/
|
||
public async getUserProfile(): Promise<TraktUser> {
|
||
return this.apiRequest<TraktUser>('/users/me?extended=full');
|
||
}
|
||
|
||
/**
|
||
* Get the user's watched movies
|
||
*/
|
||
public async getWatchedMovies(): Promise<TraktWatchedItem[]> {
|
||
return this.apiRequest<TraktWatchedItem[]>('/sync/watched/movies');
|
||
}
|
||
|
||
/**
|
||
* Get the user's watched shows
|
||
*/
|
||
public async getWatchedShows(): Promise<TraktWatchedItem[]> {
|
||
return this.apiRequest<TraktWatchedItem[]>('/sync/watched/shows');
|
||
}
|
||
|
||
/**
|
||
* Get the user's watchlist movies
|
||
*/
|
||
public async getWatchlistMovies(): Promise<TraktWatchlistItem[]> {
|
||
return this.apiRequest<TraktWatchlistItem[]>('/sync/watchlist/movies');
|
||
}
|
||
|
||
/**
|
||
* Get the user's watchlist shows
|
||
*/
|
||
public async getWatchlistShows(): Promise<TraktWatchlistItem[]> {
|
||
return this.apiRequest<TraktWatchlistItem[]>('/sync/watchlist/shows');
|
||
}
|
||
|
||
/**
|
||
* Get the user's collection movies
|
||
*/
|
||
public async getCollectionMovies(): Promise<TraktCollectionItem[]> {
|
||
return this.apiRequest<TraktCollectionItem[]>('/sync/collection/movies');
|
||
}
|
||
|
||
/**
|
||
* Get the user's collection shows
|
||
*/
|
||
public async getCollectionShows(): Promise<TraktCollectionItem[]> {
|
||
return this.apiRequest<TraktCollectionItem[]>('/sync/collection/shows');
|
||
}
|
||
|
||
/**
|
||
* Get the user's ratings
|
||
*/
|
||
public async getRatings(type?: 'movies' | 'shows'): Promise<TraktRatingItem[]> {
|
||
const endpoint = type ? `/sync/ratings/${type}` : '/sync/ratings';
|
||
return this.apiRequest<TraktRatingItem[]>(endpoint);
|
||
}
|
||
|
||
/**
|
||
* Get the user's watched movies with images
|
||
*/
|
||
public async getWatchedMoviesWithImages(): Promise<TraktWatchedItem[]> {
|
||
return this.apiRequest<TraktWatchedItem[]>('/sync/watched/movies?extended=images');
|
||
}
|
||
|
||
/**
|
||
* Get the user's watched shows with images
|
||
*/
|
||
public async getWatchedShowsWithImages(): Promise<TraktWatchedItem[]> {
|
||
return this.apiRequest<TraktWatchedItem[]>('/sync/watched/shows?extended=images');
|
||
}
|
||
|
||
/**
|
||
* Get the user's watchlist movies with images
|
||
*/
|
||
public async getWatchlistMoviesWithImages(): Promise<TraktWatchlistItem[]> {
|
||
return this.apiRequest<TraktWatchlistItem[]>('/sync/watchlist/movies?extended=images');
|
||
}
|
||
|
||
/**
|
||
* Get the user's watchlist shows with images
|
||
*/
|
||
public async getWatchlistShowsWithImages(): Promise<TraktWatchlistItem[]> {
|
||
return this.apiRequest<TraktWatchlistItem[]>('/sync/watchlist/shows?extended=images');
|
||
}
|
||
|
||
/**
|
||
* Get the user's collection movies with images
|
||
*/
|
||
public async getCollectionMoviesWithImages(): Promise<TraktCollectionItem[]> {
|
||
return this.apiRequest<TraktCollectionItem[]>('/sync/collection/movies?extended=images');
|
||
}
|
||
|
||
/**
|
||
* Get the user's collection shows with images
|
||
*/
|
||
public async getCollectionShowsWithImages(): Promise<TraktCollectionItem[]> {
|
||
return this.apiRequest<TraktCollectionItem[]>('/sync/collection/shows?extended=images');
|
||
}
|
||
|
||
/**
|
||
* Get the user's ratings with images
|
||
*/
|
||
public async getRatingsWithImages(type?: 'movies' | 'shows'): Promise<TraktRatingItem[]> {
|
||
const endpoint = type ? `/sync/ratings/${type}?extended=images` : '/sync/ratings?extended=images';
|
||
return this.apiRequest<TraktRatingItem[]>(endpoint);
|
||
}
|
||
|
||
/**
|
||
* Get playback progress with images
|
||
*/
|
||
public async getPlaybackProgressWithImages(type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> {
|
||
try {
|
||
const endpoint = type ? `/sync/playback/${type}?extended=images` : '/sync/playback?extended=images';
|
||
return this.apiRequest<TraktPlaybackItem[]>(endpoint);
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to get playback progress with images:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extract poster URL from Trakt images with basic caching
|
||
*/
|
||
public static getTraktPosterUrl(images?: TraktImages): string | null {
|
||
if (!images || !images.poster || images.poster.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// Get the first poster and add https prefix
|
||
const posterPath = images.poster[0];
|
||
const fullUrl = posterPath.startsWith('http') ? posterPath : `https://${posterPath}`;
|
||
|
||
// Try to use cached version synchronously (basic cache check)
|
||
const isCached = imageCacheService.isCached(fullUrl);
|
||
if (isCached) {
|
||
logger.log(`[TraktService] 🎯 Using cached poster: ${fullUrl.substring(0, 60)}...`);
|
||
} else {
|
||
logger.log(`[TraktService] 📥 New poster URL: ${fullUrl.substring(0, 60)}...`);
|
||
// Queue for async caching
|
||
imageCacheService.getCachedImageUrl(fullUrl).catch(error => {
|
||
logger.error('[TraktService] Background caching failed:', error);
|
||
});
|
||
}
|
||
|
||
return fullUrl;
|
||
}
|
||
|
||
/**
|
||
* Extract poster URL from Trakt images with async caching
|
||
*/
|
||
public static async getTraktPosterUrlCached(images?: TraktImages): Promise<string | null> {
|
||
const url = this.getTraktPosterUrl(images);
|
||
if (!url) return null;
|
||
|
||
try {
|
||
return await imageCacheService.getCachedImageUrl(url);
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to cache image:', error);
|
||
return url;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Extract fanart URL from Trakt images
|
||
*/
|
||
public static getTraktFanartUrl(images?: TraktImages): string | null {
|
||
if (!images || !images.fanart || images.fanart.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// Get the first fanart and add https prefix
|
||
const fanartPath = images.fanart[0];
|
||
return fanartPath.startsWith('http') ? fanartPath : `https://${fanartPath}`;
|
||
}
|
||
|
||
/**
|
||
* Get trakt id from IMDb id
|
||
*/
|
||
public async getTraktIdFromImdbId(imdbId: string, type: 'movie' | 'show'): Promise<number | null> {
|
||
try {
|
||
// Clean IMDb ID - remove 'tt' prefix if present
|
||
const cleanImdbId = imdbId.startsWith('tt') ? imdbId.substring(2) : imdbId;
|
||
|
||
logger.log(`[TraktService] Searching Trakt for ${type} with IMDb ID: ${cleanImdbId}`);
|
||
|
||
// Try multiple search approaches
|
||
const searchUrls = [
|
||
`${TRAKT_API_URL}/search/${type}?id_type=imdb&id=${cleanImdbId}`,
|
||
`${TRAKT_API_URL}/search/${type}?query=${encodeURIComponent(cleanImdbId)}&id_type=imdb`,
|
||
// Also try with the full tt-prefixed ID in case the API accepts it
|
||
`${TRAKT_API_URL}/search/${type}?id_type=imdb&id=tt${cleanImdbId}`
|
||
];
|
||
|
||
for (const searchUrl of searchUrls) {
|
||
try {
|
||
logger.log(`[TraktService] Trying search URL: ${searchUrl}`);
|
||
|
||
const response = await fetch(searchUrl, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'trakt-api-version': '2',
|
||
'trakt-api-key': TRAKT_CLIENT_ID
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
logger.warn(`[TraktService] Search attempt failed (${response.status}): ${errorText}`);
|
||
continue; // Try next URL
|
||
}
|
||
|
||
const data = await response.json();
|
||
logger.log(`[TraktService] Search response data:`, data);
|
||
|
||
if (data && data.length > 0) {
|
||
const traktId = data[0][type]?.ids?.trakt;
|
||
if (traktId) {
|
||
logger.log(`[TraktService] Found Trakt ID: ${traktId} for IMDb ID: ${cleanImdbId}`);
|
||
return traktId;
|
||
}
|
||
}
|
||
} catch (urlError) {
|
||
logger.warn(`[TraktService] URL attempt failed:`, urlError);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
logger.warn(`[TraktService] No results found for IMDb ID: ${cleanImdbId} after trying all search methods`);
|
||
return null;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to get Trakt ID from IMDb ID:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add a movie to user's watched history
|
||
*/
|
||
public async addToWatchedMovies(imdbId: string, watchedAt: Date = new Date()): Promise<boolean> {
|
||
try {
|
||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
|
||
if (!traktId) {
|
||
return false;
|
||
}
|
||
|
||
await this.apiRequest('/sync/history', 'POST', {
|
||
movies: [
|
||
{
|
||
ids: {
|
||
trakt: traktId
|
||
},
|
||
watched_at: watchedAt.toISOString()
|
||
}
|
||
]
|
||
});
|
||
return true;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to mark movie as watched:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add a show episode to user's watched history
|
||
*/
|
||
public async addToWatchedEpisodes(
|
||
imdbId: string,
|
||
season: number,
|
||
episode: number,
|
||
watchedAt: Date = new Date()
|
||
): Promise<boolean> {
|
||
try {
|
||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||
if (!traktId) {
|
||
return false;
|
||
}
|
||
|
||
await this.apiRequest('/sync/history', 'POST', {
|
||
episodes: [
|
||
{
|
||
ids: {
|
||
trakt: traktId
|
||
},
|
||
seasons: [
|
||
{
|
||
number: season,
|
||
episodes: [
|
||
{
|
||
number: episode,
|
||
watched_at: watchedAt.toISOString()
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
});
|
||
return true;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to mark episode as watched:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if a movie is in user's watched history
|
||
*/
|
||
public async isMovieWatched(imdbId: string): Promise<boolean> {
|
||
try {
|
||
if (!this.accessToken) {
|
||
return false;
|
||
}
|
||
|
||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
|
||
if (!traktId) {
|
||
return false;
|
||
}
|
||
|
||
const response = await this.apiRequest<any[]>(`/sync/history/movies/${traktId}`);
|
||
return response.length > 0;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to check if movie is watched:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Check if a show episode is in user's watched history
|
||
*/
|
||
public async isEpisodeWatched(
|
||
imdbId: string,
|
||
season: number,
|
||
episode: number
|
||
): Promise<boolean> {
|
||
try {
|
||
if (!this.accessToken) {
|
||
return false;
|
||
}
|
||
|
||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||
if (!traktId) {
|
||
return false;
|
||
}
|
||
|
||
const response = await this.apiRequest<any[]>(
|
||
`/sync/history/episodes/${traktId}?season=${season}&episode=${episode}`
|
||
);
|
||
return response.length > 0;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to check if episode is watched:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get current playback progress from Trakt
|
||
*/
|
||
public async getPlaybackProgress(type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> {
|
||
try {
|
||
const endpoint = type ? `/sync/playback/${type}` : '/sync/playback';
|
||
return this.apiRequest<TraktPlaybackItem[]>(endpoint);
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to get playback progress:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start watching content (scrobble start)
|
||
*/
|
||
public async startWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
|
||
try {
|
||
// Validate content data before making API call
|
||
const validation = this.validateContentData(contentData);
|
||
if (!validation.isValid) {
|
||
logger.error('[TraktService] Invalid content data for start watching:', validation.errors);
|
||
return null;
|
||
}
|
||
|
||
const payload = await this.buildScrobblePayload(contentData, progress);
|
||
if (!payload) {
|
||
return null;
|
||
}
|
||
|
||
return this.apiRequest<TraktScrobbleResponse>('/scrobble/start', 'POST', payload);
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to start watching:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Pause watching content (scrobble pause)
|
||
*/
|
||
public async pauseWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
|
||
try {
|
||
// Validate content data before making API call
|
||
const validation = this.validateContentData(contentData);
|
||
if (!validation.isValid) {
|
||
logger.error('[TraktService] Invalid content data for pause watching:', validation.errors);
|
||
return null;
|
||
}
|
||
|
||
const payload = await this.buildScrobblePayload(contentData, progress);
|
||
if (!payload) {
|
||
return null;
|
||
}
|
||
|
||
return this.apiRequest<TraktScrobbleResponse>('/scrobble/pause', 'POST', payload);
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to pause watching:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stop watching content (scrobble stop) - handles completion logic
|
||
*/
|
||
public async stopWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
|
||
try {
|
||
// Validate content data before making API call
|
||
const validation = this.validateContentData(contentData);
|
||
if (!validation.isValid) {
|
||
logger.error('[TraktService] Invalid content data for stop watching:', validation.errors);
|
||
return null;
|
||
}
|
||
|
||
const payload = await this.buildScrobblePayload(contentData, progress);
|
||
if (!payload) {
|
||
return null;
|
||
}
|
||
|
||
return this.apiRequest<TraktScrobbleResponse>('/scrobble/stop', 'POST', payload);
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to stop watching:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update watching progress or mark as complete (legacy method)
|
||
* @deprecated Use specific methods: startWatching, pauseWatching, stopWatching
|
||
*/
|
||
public async updateProgress(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
|
||
// For backwards compatibility, use stop for now
|
||
return this.stopWatching(contentData, progress);
|
||
}
|
||
|
||
/**
|
||
* Validate content data before making API calls
|
||
*/
|
||
private validateContentData(contentData: TraktContentData): { isValid: boolean; errors: string[] } {
|
||
const errors: string[] = [];
|
||
|
||
if (!contentData.type || !['movie', 'episode'].includes(contentData.type)) {
|
||
errors.push('Invalid content type');
|
||
}
|
||
|
||
if (!contentData.title || contentData.title.trim() === '') {
|
||
errors.push('Missing or empty title');
|
||
}
|
||
|
||
if (!contentData.imdbId || contentData.imdbId.trim() === '') {
|
||
errors.push('Missing or empty IMDb ID');
|
||
}
|
||
|
||
if (contentData.type === 'episode') {
|
||
if (!contentData.season || contentData.season < 1) {
|
||
errors.push('Invalid season number');
|
||
}
|
||
if (!contentData.episode || contentData.episode < 1) {
|
||
errors.push('Invalid episode number');
|
||
}
|
||
if (!contentData.showTitle || contentData.showTitle.trim() === '') {
|
||
errors.push('Missing or empty show title');
|
||
}
|
||
if (!contentData.showYear || contentData.showYear < 1900) {
|
||
errors.push('Invalid show year');
|
||
}
|
||
}
|
||
|
||
return {
|
||
isValid: errors.length === 0,
|
||
errors
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Build scrobble payload for API requests
|
||
*/
|
||
private async buildScrobblePayload(contentData: TraktContentData, progress: number): Promise<any | null> {
|
||
try {
|
||
// Enhanced debug logging for payload building
|
||
logger.log('[TraktService] Building scrobble payload:', {
|
||
type: contentData.type,
|
||
title: contentData.title,
|
||
imdbId: contentData.imdbId,
|
||
year: contentData.year,
|
||
season: contentData.season,
|
||
episode: contentData.episode,
|
||
showTitle: contentData.showTitle,
|
||
showYear: contentData.showYear,
|
||
showImdbId: contentData.showImdbId,
|
||
progress: progress
|
||
});
|
||
|
||
if (contentData.type === 'movie') {
|
||
if (!contentData.imdbId || !contentData.title) {
|
||
logger.error('[TraktService] Missing movie data for scrobbling:', {
|
||
imdbId: contentData.imdbId,
|
||
title: contentData.title
|
||
});
|
||
return null;
|
||
}
|
||
|
||
// Clean IMDB ID - some APIs want it without 'tt' prefix
|
||
const cleanImdbId = contentData.imdbId.startsWith('tt')
|
||
? contentData.imdbId.substring(2)
|
||
: contentData.imdbId;
|
||
|
||
const payload = {
|
||
movie: {
|
||
title: contentData.title,
|
||
year: contentData.year,
|
||
ids: {
|
||
imdb: cleanImdbId
|
||
}
|
||
},
|
||
progress: Math.round(progress * 100) / 100 // Round to 2 decimal places
|
||
};
|
||
|
||
logger.log('[TraktService] Movie payload built:', payload);
|
||
return payload;
|
||
} else if (contentData.type === 'episode') {
|
||
if (!contentData.season || !contentData.episode || !contentData.showTitle || !contentData.showYear) {
|
||
logger.error('[TraktService] Missing episode data for scrobbling:', {
|
||
season: contentData.season,
|
||
episode: contentData.episode,
|
||
showTitle: contentData.showTitle,
|
||
showYear: contentData.showYear
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const payload: any = {
|
||
show: {
|
||
title: contentData.showTitle,
|
||
year: contentData.showYear,
|
||
ids: {}
|
||
},
|
||
episode: {
|
||
season: contentData.season,
|
||
number: contentData.episode
|
||
},
|
||
progress: Math.round(progress * 100) / 100
|
||
};
|
||
|
||
// Add show IMDB ID if available
|
||
if (contentData.showImdbId) {
|
||
const cleanShowImdbId = contentData.showImdbId.startsWith('tt')
|
||
? contentData.showImdbId.substring(2)
|
||
: contentData.showImdbId;
|
||
payload.show.ids.imdb = cleanShowImdbId;
|
||
}
|
||
|
||
// Add episode IMDB ID if available (for specific episode IDs)
|
||
if (contentData.imdbId && contentData.imdbId !== contentData.showImdbId) {
|
||
const cleanEpisodeImdbId = contentData.imdbId.startsWith('tt')
|
||
? contentData.imdbId.substring(2)
|
||
: contentData.imdbId;
|
||
|
||
if (!payload.episode.ids) {
|
||
payload.episode.ids = {};
|
||
}
|
||
|
||
payload.episode.ids.imdb = cleanEpisodeImdbId;
|
||
}
|
||
|
||
logger.log('[TraktService] Episode payload built:', payload);
|
||
return payload;
|
||
}
|
||
|
||
return null;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to build scrobble payload:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Process the request queue with proper rate limiting
|
||
*/
|
||
private async processQueue(): Promise<void> {
|
||
if (this.isProcessingQueue || this.requestQueue.length === 0) {
|
||
return;
|
||
}
|
||
|
||
this.isProcessingQueue = true;
|
||
|
||
while (this.requestQueue.length > 0) {
|
||
const request = this.requestQueue.shift();
|
||
if (request) {
|
||
try {
|
||
await request();
|
||
} catch (error) {
|
||
logger.error('[TraktService] Queue request failed:', error);
|
||
}
|
||
|
||
// Wait minimum interval before next request
|
||
if (this.requestQueue.length > 0) {
|
||
await new Promise(resolve => setTimeout(resolve, this.MIN_API_INTERVAL));
|
||
}
|
||
}
|
||
}
|
||
|
||
this.isProcessingQueue = false;
|
||
}
|
||
|
||
/**
|
||
* Add request to queue for rate-limited processing
|
||
*/
|
||
private queueRequest<T>(requestFn: () => Promise<T>): Promise<T> {
|
||
return new Promise((resolve, reject) => {
|
||
this.requestQueue.push(async () => {
|
||
try {
|
||
const result = await requestFn();
|
||
resolve(result);
|
||
} catch (error) {
|
||
reject(error);
|
||
}
|
||
});
|
||
|
||
// Start processing if not already running
|
||
this.processQueue();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Generate a unique key for content being watched
|
||
*/
|
||
private getWatchingKey(contentData: TraktContentData): string {
|
||
if (contentData.type === 'movie') {
|
||
return `movie:${contentData.imdbId}`;
|
||
} else {
|
||
return `episode:${contentData.showImdbId || contentData.imdbId}:S${contentData.season}E${contentData.episode}`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start watching content (use when playback begins)
|
||
*/
|
||
public async scrobbleStart(contentData: TraktContentData, progress: number): Promise<boolean> {
|
||
try {
|
||
if (!await this.isAuthenticated()) {
|
||
return false;
|
||
}
|
||
|
||
const watchingKey = this.getWatchingKey(contentData);
|
||
|
||
// Check if this content was recently scrobbled (to prevent duplicates from component remounts)
|
||
if (this.isRecentlyScrobbled(contentData)) {
|
||
logger.log(`[TraktService] Content was recently scrobbled, skipping start: ${contentData.title}`);
|
||
return true;
|
||
}
|
||
|
||
// ENHANCED PROTECTION: Check if we recently stopped this content with high progress
|
||
// This prevents restarting sessions for content that was just completed
|
||
const lastStopTime = this.lastStopCalls.get(watchingKey);
|
||
if (lastStopTime && (Date.now() - lastStopTime) < 30000) { // 30 seconds
|
||
logger.log(`[TraktService] Recently stopped this content (${((Date.now() - lastStopTime) / 1000).toFixed(1)}s ago), preventing restart: ${contentData.title}`);
|
||
return true;
|
||
}
|
||
|
||
// Debug log removed to reduce terminal noise
|
||
|
||
// Only start if not already watching this content
|
||
if (this.currentlyWatching.has(watchingKey)) {
|
||
logger.log(`[TraktService] Already watching this content, skipping start: ${contentData.title}`);
|
||
return true; // Already started
|
||
}
|
||
|
||
const result = await this.queueRequest(async () => {
|
||
return await this.startWatching(contentData, progress);
|
||
});
|
||
|
||
if (result) {
|
||
this.currentlyWatching.add(watchingKey);
|
||
logger.log(`[TraktService] Started watching ${contentData.type}: ${contentData.title}`);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to start scrobbling:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update progress while watching (use for periodic progress updates)
|
||
*/
|
||
public async scrobblePause(contentData: TraktContentData, progress: number, force: boolean = false): Promise<boolean> {
|
||
try {
|
||
if (!await this.isAuthenticated()) {
|
||
return false;
|
||
}
|
||
|
||
const now = Date.now();
|
||
|
||
const watchingKey = this.getWatchingKey(contentData);
|
||
const lastSync = this.lastSyncTimes.get(watchingKey) || 0;
|
||
|
||
// IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 500ms)
|
||
if (!force && (now - lastSync) < 500) {
|
||
return true; // Skip this sync, but return success
|
||
}
|
||
|
||
this.lastSyncTimes.set(watchingKey, now);
|
||
|
||
const result = await this.queueRequest(async () => {
|
||
return await this.pauseWatching(contentData, progress);
|
||
});
|
||
|
||
if (result) {
|
||
logger.log(`[TraktService] Updated progress ${progress.toFixed(1)}% for ${contentData.type}: ${contentData.title}`);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
} catch (error) {
|
||
// Handle rate limiting errors more gracefully
|
||
if (error instanceof Error && error.message.includes('429')) {
|
||
logger.warn('[TraktService] Rate limited, will retry later');
|
||
return true; // Return success to avoid error spam
|
||
}
|
||
|
||
logger.error('[TraktService] Failed to update progress:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stop watching content (use when playback ends or stops)
|
||
*/
|
||
public async scrobbleStop(contentData: TraktContentData, progress: number): Promise<boolean> {
|
||
try {
|
||
if (!await this.isAuthenticated()) {
|
||
return false;
|
||
}
|
||
|
||
const watchingKey = this.getWatchingKey(contentData);
|
||
const now = Date.now();
|
||
|
||
// IMMEDIATE SYNC: Reduce debouncing for instant sync, only prevent truly duplicate calls (< 1 second)
|
||
const lastStopTime = this.lastStopCalls.get(watchingKey);
|
||
if (lastStopTime && (now - lastStopTime) < 1000) {
|
||
logger.log(`[TraktService] Ignoring duplicate stop call for ${contentData.title} (last stop ${((now - lastStopTime) / 1000).toFixed(1)}s ago)`);
|
||
return true; // Return success to avoid error handling
|
||
}
|
||
|
||
// Record this stop attempt
|
||
this.lastStopCalls.set(watchingKey, now);
|
||
|
||
const result = await this.queueRequest(async () => {
|
||
return await this.stopWatching(contentData, progress);
|
||
});
|
||
|
||
if (result) {
|
||
this.currentlyWatching.delete(watchingKey);
|
||
|
||
// Mark as scrobbled if >= 80% to prevent future duplicates and restarts
|
||
if (progress >= this.completionThreshold) {
|
||
this.scrobbledItems.add(watchingKey);
|
||
this.scrobbledTimestamps.set(watchingKey, Date.now());
|
||
logger.log(`[TraktService] Marked as scrobbled to prevent restarts: ${watchingKey}`);
|
||
}
|
||
|
||
// The stop endpoint automatically handles the 80%+ completion logic
|
||
// and will mark as scrobbled if >= 80%, or pause if < 80%
|
||
const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused';
|
||
logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
|
||
|
||
return true;
|
||
} else {
|
||
// If failed, remove from lastStopCalls so we can try again
|
||
this.lastStopCalls.delete(watchingKey);
|
||
}
|
||
|
||
return false;
|
||
} catch (error) {
|
||
// Handle rate limiting errors more gracefully
|
||
if (error instanceof Error && error.message.includes('429')) {
|
||
logger.warn('[TraktService] Rate limited, will retry later');
|
||
return true;
|
||
}
|
||
|
||
logger.error('[TraktService] Failed to stop scrobbling:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* IMMEDIATE SCROBBLE METHODS - Bypass rate limiting queue for critical user actions
|
||
*/
|
||
|
||
/**
|
||
* Immediate scrobble pause - bypasses queue for instant user feedback
|
||
*/
|
||
public async scrobblePauseImmediate(contentData: TraktContentData, progress: number): Promise<boolean> {
|
||
try {
|
||
if (!await this.isAuthenticated()) {
|
||
return false;
|
||
}
|
||
|
||
const watchingKey = this.getWatchingKey(contentData);
|
||
|
||
// MINIMAL DEDUPLICATION: Only prevent calls within 100ms for immediate actions
|
||
const lastSync = this.lastSyncTimes.get(watchingKey) || 0;
|
||
if ((Date.now() - lastSync) < 100) {
|
||
return true; // Skip this sync, but return success
|
||
}
|
||
|
||
this.lastSyncTimes.set(watchingKey, Date.now());
|
||
|
||
// BYPASS QUEUE: Call API directly for immediate response
|
||
const result = await this.pauseWatching(contentData, progress);
|
||
|
||
if (result) {
|
||
logger.log(`[TraktService] IMMEDIATE: Updated progress ${progress.toFixed(1)}% for ${contentData.type}: ${contentData.title}`);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to pause scrobbling immediately:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Immediate scrobble stop - bypasses queue for instant user feedback
|
||
*/
|
||
public async scrobbleStopImmediate(contentData: TraktContentData, progress: number): Promise<boolean> {
|
||
try {
|
||
if (!await this.isAuthenticated()) {
|
||
return false;
|
||
}
|
||
|
||
const watchingKey = this.getWatchingKey(contentData);
|
||
|
||
// MINIMAL DEDUPLICATION: Only prevent calls within 200ms for immediate actions
|
||
const lastStopTime = this.lastStopCalls.get(watchingKey);
|
||
if (lastStopTime && (Date.now() - lastStopTime) < 200) {
|
||
return true;
|
||
}
|
||
|
||
this.lastStopCalls.set(watchingKey, Date.now());
|
||
|
||
// BYPASS QUEUE: Call API directly for immediate response
|
||
const result = await this.stopWatching(contentData, progress);
|
||
|
||
if (result) {
|
||
this.currentlyWatching.delete(watchingKey);
|
||
|
||
// Mark as scrobbled if >= 80% to prevent future duplicates and restarts
|
||
if (progress >= this.completionThreshold) {
|
||
this.scrobbledItems.add(watchingKey);
|
||
this.scrobbledTimestamps.set(watchingKey, Date.now());
|
||
}
|
||
|
||
const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused';
|
||
logger.log(`[TraktService] IMMEDIATE: Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
|
||
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to stop scrobbling immediately:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Legacy sync method - now delegates to proper scrobble methods
|
||
* @deprecated Use scrobbleStart, scrobblePause, scrobbleStop instead
|
||
*/
|
||
public async syncProgressToTrakt(
|
||
contentData: TraktContentData,
|
||
progress: number,
|
||
force: boolean = false
|
||
): Promise<boolean> {
|
||
// For backward compatibility, treat as a pause update
|
||
return this.scrobblePause(contentData, progress, force);
|
||
}
|
||
|
||
/**
|
||
* Debug method to test Trakt API connection and scrobble functionality
|
||
*/
|
||
public async debugTraktConnection(): Promise<any> {
|
||
try {
|
||
logger.log('[TraktService] Testing Trakt API connection...');
|
||
|
||
// Test basic API access
|
||
const userResponse = await this.apiRequest('/users/me', 'GET');
|
||
logger.log('[TraktService] User info:', userResponse);
|
||
|
||
// Test a minimal scrobble start to verify API works
|
||
const testPayload = {
|
||
movie: {
|
||
title: "Test Movie",
|
||
year: 2023,
|
||
ids: {
|
||
imdb: "1234567" // Fake IMDB ID for testing
|
||
}
|
||
},
|
||
progress: 1.0
|
||
};
|
||
|
||
logger.log('[TraktService] Testing scrobble/start endpoint with test payload...');
|
||
const scrobbleResponse = await this.apiRequest('/scrobble/start', 'POST', testPayload);
|
||
logger.log('[TraktService] Scrobble test response:', scrobbleResponse);
|
||
|
||
return {
|
||
authenticated: true,
|
||
user: userResponse,
|
||
scrobbleTest: scrobbleResponse
|
||
};
|
||
} catch (error) {
|
||
logger.error('[TraktService] Debug connection failed:', error);
|
||
return {
|
||
authenticated: false,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Debug method to check current playback progress on Trakt
|
||
*/
|
||
public async debugPlaybackProgress(): Promise<void> {
|
||
try {
|
||
if (!await this.isAuthenticated()) {
|
||
// Debug logging removed
|
||
return;
|
||
}
|
||
|
||
const progress = await this.getPlaybackProgress();
|
||
// Progress logging removed
|
||
|
||
progress.forEach((item, index) => {
|
||
if (item.type === 'movie' && item.movie) {
|
||
// Movie progress logging removed
|
||
} else if (item.type === 'episode' && item.episode && item.show) {
|
||
// Episode progress logging removed
|
||
}
|
||
});
|
||
|
||
if (progress.length === 0) {
|
||
// No progress logging removed
|
||
}
|
||
} catch (error) {
|
||
logger.error('[TraktService] DEBUG: Error fetching playback progress:', error);
|
||
}
|
||
}
|
||
/**
|
||
* Debug image cache status
|
||
*/
|
||
public static debugImageCache(): void {
|
||
try {
|
||
logger.log('[TraktService] === IMAGE CACHE DEBUG ===');
|
||
imageCacheService.logCacheStatus();
|
||
} catch (error) {
|
||
logger.error('[TraktService] Debug image cache failed:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Delete a playback progress entry on Trakt by its playback `id`.
|
||
* Returns true if the request succeeded (204).
|
||
*/
|
||
public async deletePlaybackItem(playbackId: number): Promise<boolean> {
|
||
try {
|
||
if (!this.accessToken) return false;
|
||
await this.apiRequest<null>(`/sync/playback/${playbackId}`, 'DELETE');
|
||
return true; // trakt returns 204 no-content on success
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to delete playback item:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Convenience helper: find a playback entry matching imdb id (and optional season/episode) and delete it.
|
||
*/
|
||
public async deletePlaybackForContent(imdbId: string, type: 'movie' | 'series', season?: number, episode?: number): Promise<boolean> {
|
||
try {
|
||
logger.log(`🔍 [TraktService] deletePlaybackForContent called for ${type}:${imdbId} (season:${season}, episode:${episode})`);
|
||
|
||
if (!this.accessToken) {
|
||
logger.log(`❌ [TraktService] No access token - cannot delete playback`);
|
||
return false;
|
||
}
|
||
|
||
logger.log(`🔍 [TraktService] Fetching current playback progress...`);
|
||
const progressItems = await this.getPlaybackProgress();
|
||
logger.log(`📊 [TraktService] Found ${progressItems.length} playback items`);
|
||
|
||
const target = progressItems.find(item => {
|
||
if (type === 'movie' && item.type === 'movie' && item.movie?.ids.imdb === imdbId) {
|
||
logger.log(`🎯 [TraktService] Found matching movie: ${item.movie?.title}`);
|
||
return true;
|
||
}
|
||
if (type === 'series' && item.type === 'episode' && item.show?.ids.imdb === imdbId) {
|
||
if (season !== undefined && episode !== undefined) {
|
||
const matches = item.episode?.season === season && item.episode?.number === episode;
|
||
if (matches) {
|
||
logger.log(`🎯 [TraktService] Found matching episode: ${item.show?.title} S${season}E${episode}`);
|
||
}
|
||
return matches;
|
||
}
|
||
logger.log(`🎯 [TraktService] Found matching series episode: ${item.show?.title} S${item.episode?.season}E${item.episode?.number}`);
|
||
return true; // match any episode of the show if specific not provided
|
||
}
|
||
return false;
|
||
});
|
||
|
||
if (target) {
|
||
logger.log(`🗑️ [TraktService] Deleting playback item with ID: ${target.id}`);
|
||
const result = await this.deletePlaybackItem(target.id);
|
||
logger.log(`✅ [TraktService] Delete result: ${result}`);
|
||
return result;
|
||
} else {
|
||
logger.log(`ℹ️ [TraktService] No matching playback item found for ${type}:${imdbId}`);
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
logger.error(`❌ [TraktService] Error deleting playback for content ${type}:${imdbId}:`, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public async getWatchedEpisodesHistory(page: number = 1, limit: number = 100): Promise<any[]> {
|
||
await this.ensureInitialized();
|
||
|
||
const cacheKey = `history_episodes_${page}_${limit}`;
|
||
const lastSync = this.lastSyncTimes.get(cacheKey) || 0;
|
||
const now = Date.now();
|
||
if (now - lastSync < this.SYNC_DEBOUNCE_MS) {
|
||
// Return cached result if we fetched recently
|
||
return (this as any)[cacheKey] || [];
|
||
}
|
||
|
||
const endpoint = `/sync/history/episodes?page=${page}&limit=${limit}`;
|
||
try {
|
||
const data = await this.apiRequest<any[]>(endpoint, 'GET');
|
||
(this as any)[cacheKey] = data;
|
||
this.lastSyncTimes.set(cacheKey, now);
|
||
return data;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to fetch watched episodes history:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Remove items from user's watched history
|
||
*/
|
||
public async removeFromHistory(payload: TraktHistoryRemovePayload): Promise<TraktHistoryRemoveResponse | null> {
|
||
try {
|
||
logger.log(`🔍 [TraktService] removeFromHistory called with payload:`, JSON.stringify(payload, null, 2));
|
||
|
||
if (!await this.isAuthenticated()) {
|
||
logger.log(`❌ [TraktService] Not authenticated for removeFromHistory`);
|
||
return null;
|
||
}
|
||
|
||
const result = await this.apiRequest<TraktHistoryRemoveResponse>('/sync/history/remove', 'POST', payload);
|
||
|
||
logger.log(`📥 [TraktService] removeFromHistory API response:`, JSON.stringify(result, null, 2));
|
||
|
||
return result;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to remove from history:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get user's watch history with optional filtering
|
||
*/
|
||
public async getHistory(
|
||
type?: 'movies' | 'shows' | 'episodes',
|
||
id?: number,
|
||
startAt?: Date,
|
||
endAt?: Date,
|
||
page: number = 1,
|
||
limit: number = 100
|
||
): Promise<TraktHistoryItem[]> {
|
||
try {
|
||
if (!await this.isAuthenticated()) {
|
||
return [];
|
||
}
|
||
|
||
let endpoint = '/sync/history';
|
||
if (type) {
|
||
endpoint += `/${type}`;
|
||
if (id) {
|
||
endpoint += `/${id}`;
|
||
}
|
||
}
|
||
|
||
const params = new URLSearchParams();
|
||
params.append('page', page.toString());
|
||
params.append('limit', limit.toString());
|
||
|
||
if (startAt) {
|
||
params.append('start_at', startAt.toISOString());
|
||
}
|
||
|
||
if (endAt) {
|
||
params.append('end_at', endAt.toISOString());
|
||
}
|
||
|
||
const queryString = params.toString();
|
||
if (queryString) {
|
||
endpoint += `?${queryString}`;
|
||
}
|
||
|
||
return this.apiRequest<TraktHistoryItem[]>(endpoint, 'GET');
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to get history:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get user's movie watch history
|
||
*/
|
||
public async getHistoryMovies(
|
||
startAt?: Date,
|
||
endAt?: Date,
|
||
page: number = 1,
|
||
limit: number = 100
|
||
): Promise<TraktHistoryItem[]> {
|
||
return this.getHistory('movies', undefined, startAt, endAt, page, limit);
|
||
}
|
||
|
||
/**
|
||
* Get user's episode watch history
|
||
*/
|
||
public async getHistoryEpisodes(
|
||
startAt?: Date,
|
||
endAt?: Date,
|
||
page: number = 1,
|
||
limit: number = 100
|
||
): Promise<TraktHistoryItem[]> {
|
||
return this.getHistory('episodes', undefined, startAt, endAt, page, limit);
|
||
}
|
||
|
||
/**
|
||
* Get user's show watch history
|
||
*/
|
||
public async getHistoryShows(
|
||
startAt?: Date,
|
||
endAt?: Date,
|
||
page: number = 1,
|
||
limit: number = 100
|
||
): Promise<TraktHistoryItem[]> {
|
||
return this.getHistory('shows', undefined, startAt, endAt, page, limit);
|
||
}
|
||
|
||
/**
|
||
* Remove a movie from watched history by IMDB ID
|
||
*/
|
||
public async removeMovieFromHistory(imdbId: string): Promise<boolean> {
|
||
try {
|
||
const payload: TraktHistoryRemovePayload = {
|
||
movies: [
|
||
{
|
||
ids: {
|
||
imdb: imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`
|
||
}
|
||
}
|
||
]
|
||
};
|
||
|
||
const result = await this.removeFromHistory(payload);
|
||
return result !== null && result.deleted.movies > 0;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to remove movie from history:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Remove an episode from watched history by IMDB IDs
|
||
*/
|
||
public async removeEpisodeFromHistory(showImdbId: string, season: number, episode: number): Promise<boolean> {
|
||
try {
|
||
logger.log(`🔍 [TraktService] removeEpisodeFromHistory called for ${showImdbId} S${season}E${episode}`);
|
||
const payload: TraktHistoryRemovePayload = {
|
||
shows: [
|
||
{
|
||
ids: {
|
||
imdb: showImdbId.startsWith('tt') ? showImdbId : `tt${showImdbId}`
|
||
},
|
||
seasons: [
|
||
{
|
||
number: season,
|
||
episodes: [
|
||
{
|
||
number: episode
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
};
|
||
|
||
logger.log(`📤 [TraktService] Sending removeEpisodeFromHistory payload:`, JSON.stringify(payload, null, 2));
|
||
|
||
const result = await this.removeFromHistory(payload);
|
||
|
||
if (result) {
|
||
const success = result.deleted.episodes > 0;
|
||
logger.log(`✅ [TraktService] Episode removal success: ${success} (${result.deleted.episodes} episodes deleted)`);
|
||
return success;
|
||
}
|
||
|
||
logger.log(`❌ [TraktService] No result from removeEpisodeFromHistory`);
|
||
return false;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to remove episode from history:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Remove entire show from watched history by IMDB ID
|
||
*/
|
||
public async removeShowFromHistory(imdbId: string): Promise<boolean> {
|
||
try {
|
||
logger.log(`🔍 [TraktService] removeShowFromHistory called for ${imdbId}`);
|
||
|
||
// First, let's check if this show exists in history
|
||
logger.log(`🔍 [TraktService] Checking if ${imdbId} exists in watch history...`);
|
||
const history = await this.getHistoryEpisodes(undefined, undefined, 1, 200); // Get recent episode history
|
||
const fullImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
|
||
const showInHistory = history.some(item =>
|
||
item.show?.ids?.imdb === fullImdbId
|
||
);
|
||
|
||
logger.log(`📊 [TraktService] Show ${imdbId} found in history: ${showInHistory}`);
|
||
|
||
if (!showInHistory) {
|
||
logger.log(`ℹ️ [TraktService] Show ${imdbId} not found in watch history - nothing to remove`);
|
||
return true; // Consider this a success since there's nothing to remove
|
||
}
|
||
|
||
const payload: TraktHistoryRemovePayload = {
|
||
shows: [
|
||
{
|
||
ids: {
|
||
imdb: imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`
|
||
}
|
||
}
|
||
]
|
||
};
|
||
|
||
logger.log(`📤 [TraktService] Sending removeFromHistory payload:`, JSON.stringify(payload, null, 2));
|
||
|
||
const result = await this.removeFromHistory(payload);
|
||
|
||
logger.log(`📥 [TraktService] removeFromHistory response:`, JSON.stringify(result, null, 2));
|
||
|
||
if (result) {
|
||
const success = result.deleted.episodes > 0;
|
||
logger.log(`✅ [TraktService] Show removal success: ${success} (${result.deleted.episodes} episodes deleted)`);
|
||
return success;
|
||
}
|
||
|
||
logger.log(`❌ [TraktService] No response from removeFromHistory API`);
|
||
return false;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to remove show from history:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Remove items from history by history IDs
|
||
*/
|
||
public async removeHistoryByIds(historyIds: number[]): Promise<boolean> {
|
||
try {
|
||
const payload: TraktHistoryRemovePayload = {
|
||
ids: historyIds
|
||
};
|
||
|
||
const result = await this.removeFromHistory(payload);
|
||
return result !== null && (result.deleted.movies > 0 || result.deleted.episodes > 0);
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to remove history by IDs:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get trakt id from TMDB id (fallback method)
|
||
*/
|
||
public async getTraktIdFromTmdbId(tmdbId: number, type: 'movie' | 'show'): Promise<number | null> {
|
||
try {
|
||
logger.log(`[TraktService] Searching Trakt for ${type} with TMDB ID: ${tmdbId}`);
|
||
|
||
const response = await fetch(`${TRAKT_API_URL}/search/${type}?id_type=tmdb&id=${tmdbId}`, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'trakt-api-version': '2',
|
||
'trakt-api-key': TRAKT_CLIENT_ID
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
logger.warn(`[TraktService] TMDB search failed (${response.status}): ${errorText}`);
|
||
return null;
|
||
}
|
||
|
||
const data = await response.json();
|
||
logger.log(`[TraktService] TMDB search response:`, data);
|
||
if (data && data.length > 0) {
|
||
const traktId = data[0][type]?.ids?.trakt;
|
||
if (traktId) {
|
||
logger.log(`[TraktService] Found Trakt ID via TMDB: ${traktId} for TMDB ID: ${tmdbId}`);
|
||
return traktId;
|
||
}
|
||
}
|
||
|
||
logger.warn(`[TraktService] No TMDB results found for TMDB ID: ${tmdbId}`);
|
||
return null;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to get Trakt ID from TMDB ID:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get comments for a movie
|
||
*/
|
||
public async getMovieComments(imdbId: string, tmdbId?: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||
try {
|
||
let traktId = await this.getTraktIdFromImdbId(imdbId, 'movie');
|
||
|
||
// Fallback to TMDB ID if IMDb search failed
|
||
if (!traktId && tmdbId) {
|
||
logger.log(`[TraktService] IMDb search failed, trying TMDB ID: ${tmdbId}`);
|
||
traktId = await this.getTraktIdFromTmdbId(tmdbId, 'movie');
|
||
}
|
||
|
||
if (!traktId) {
|
||
logger.warn(`[TraktService] Could not find Trakt ID for movie with IMDb: ${imdbId}, TMDB: ${tmdbId}`);
|
||
return [];
|
||
}
|
||
|
||
const endpoint = `/movies/${traktId}/comments?page=${page}&limit=${limit}`;
|
||
const result = await this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||
console.log(`[TraktService] Movie comments response:`, result);
|
||
return result;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to get movie comments:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get comments for a show
|
||
*/
|
||
public async getShowComments(imdbId: string, tmdbId?: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||
try {
|
||
let traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||
|
||
// Fallback to TMDB ID if IMDb search failed
|
||
if (!traktId && tmdbId) {
|
||
logger.log(`[TraktService] IMDb search failed, trying TMDB ID: ${tmdbId}`);
|
||
traktId = await this.getTraktIdFromTmdbId(tmdbId, 'show');
|
||
}
|
||
|
||
if (!traktId) {
|
||
logger.warn(`[TraktService] Could not find Trakt ID for show with IMDb: ${imdbId}, TMDB: ${tmdbId}`);
|
||
return [];
|
||
}
|
||
|
||
const endpoint = `/shows/${traktId}/comments?page=${page}&limit=${limit}`;
|
||
const result = await this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||
console.log(`[TraktService] Show comments response:`, result);
|
||
return result;
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to get show comments:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get comments for a season
|
||
*/
|
||
public async getSeasonComments(imdbId: string, season: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||
try {
|
||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||
if (!traktId) {
|
||
return [];
|
||
}
|
||
|
||
const endpoint = `/shows/${traktId}/seasons/${season}/comments?page=${page}&limit=${limit}`;
|
||
return this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to get season comments:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get comments for an episode
|
||
*/
|
||
public async getEpisodeComments(imdbId: string, season: number, episode: number, page: number = 1, limit: number = 10): Promise<TraktContentComment[]> {
|
||
try {
|
||
const traktId = await this.getTraktIdFromImdbId(imdbId, 'show');
|
||
if (!traktId) {
|
||
return [];
|
||
}
|
||
|
||
const endpoint = `/shows/${traktId}/seasons/${season}/episodes/${episode}/comments?page=${page}&limit=${limit}`;
|
||
return this.apiRequest<TraktContentComment[]>(endpoint, 'GET');
|
||
} catch (error) {
|
||
logger.error('[TraktService] Failed to get episode comments:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle app state changes to reduce memory pressure
|
||
*/
|
||
private handleAppStateChange = (nextState: AppStateStatus) => {
|
||
if (nextState !== 'active') {
|
||
// Clear tracking maps to reduce memory pressure when app goes to background
|
||
this.scrobbledItems.clear();
|
||
this.scrobbledTimestamps.clear();
|
||
this.currentlyWatching.clear();
|
||
this.lastSyncTimes.clear();
|
||
this.lastStopCalls.clear();
|
||
|
||
// Clear request queue to prevent background processing
|
||
this.requestQueue = [];
|
||
this.isProcessingQueue = false;
|
||
}
|
||
};
|
||
}
|
||
|
||
// Export a singleton instance
|
||
export const traktService = TraktService.getInstance(); |