mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-15 07:26:02 +00:00
507 lines
19 KiB
TypeScript
507 lines
19 KiB
TypeScript
import { TraktService } from './traktService';
|
|
import { SimklService } from './simklService';
|
|
import { storageService } from './storageService';
|
|
import { mmkvStorage } from './mmkvStorage';
|
|
import { logger } from '../utils/logger';
|
|
|
|
/**
|
|
* WatchedService - Manages "watched" status for movies, episodes, and seasons.
|
|
* Handles both local storage and Trakt sync transparently.
|
|
*
|
|
* When Trakt is authenticated, it syncs to Trakt.
|
|
* When not authenticated, it stores locally.
|
|
*/
|
|
class WatchedService {
|
|
private static instance: WatchedService;
|
|
private traktService: TraktService;
|
|
private simklService: SimklService;
|
|
|
|
private constructor() {
|
|
this.traktService = TraktService.getInstance();
|
|
this.simklService = SimklService.getInstance();
|
|
}
|
|
|
|
public static getInstance(): WatchedService {
|
|
if (!WatchedService.instance) {
|
|
WatchedService.instance = new WatchedService();
|
|
}
|
|
return WatchedService.instance;
|
|
}
|
|
|
|
/**
|
|
* Mark a movie as watched
|
|
* @param imdbId - The IMDb ID of the movie
|
|
* @param watchedAt - Optional date when watched
|
|
*/
|
|
public async markMovieAsWatched(
|
|
imdbId: string,
|
|
watchedAt: Date = new Date()
|
|
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
|
try {
|
|
logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`);
|
|
|
|
// Check if Trakt is authenticated
|
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
|
let syncedToTrakt = false;
|
|
|
|
if (isTraktAuth) {
|
|
// Sync to Trakt
|
|
syncedToTrakt = await this.traktService.addToWatchedMovies(imdbId, watchedAt);
|
|
logger.log(`[WatchedService] Trakt sync result for movie: ${syncedToTrakt}`);
|
|
}
|
|
|
|
// Sync to Simkl
|
|
const isSimklAuth = await this.simklService.isAuthenticated();
|
|
if (isSimklAuth) {
|
|
await this.simklService.addToHistory({ movies: [{ ids: { imdb: imdbId }, watched_at: watchedAt.toISOString() }] });
|
|
logger.log(`[WatchedService] Simkl sync request sent for movie`);
|
|
}
|
|
|
|
// Also store locally as "completed" (100% progress)
|
|
await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt);
|
|
|
|
return { success: true, syncedToTrakt };
|
|
} catch (error) {
|
|
logger.error('[WatchedService] Failed to mark movie as watched:', error);
|
|
return { success: false, syncedToTrakt: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark a single episode as watched
|
|
* @param showImdbId - The IMDb ID of the show
|
|
* @param showId - The Stremio ID of the show (for local storage)
|
|
* @param season - Season number
|
|
* @param episode - Episode number
|
|
* @param watchedAt - Optional date when watched
|
|
*/
|
|
public async markEpisodeAsWatched(
|
|
showImdbId: string,
|
|
showId: string,
|
|
season: number,
|
|
episode: number,
|
|
watchedAt: Date = new Date()
|
|
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
|
try {
|
|
logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`);
|
|
|
|
// Check if Trakt is authenticated
|
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
|
let syncedToTrakt = false;
|
|
|
|
if (isTraktAuth) {
|
|
// Sync to Trakt
|
|
syncedToTrakt = await this.traktService.addToWatchedEpisodes(
|
|
showImdbId,
|
|
season,
|
|
episode,
|
|
watchedAt
|
|
);
|
|
logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`);
|
|
}
|
|
|
|
// Sync to Simkl
|
|
const isSimklAuth = await this.simklService.isAuthenticated();
|
|
if (isSimklAuth) {
|
|
// Simkl structure: shows -> seasons -> episodes
|
|
await this.simklService.addToHistory({
|
|
shows: [{
|
|
ids: { imdb: showImdbId },
|
|
seasons: [{
|
|
number: season,
|
|
episodes: [{ number: episode, watched_at: watchedAt.toISOString() }]
|
|
}]
|
|
}]
|
|
});
|
|
logger.log(`[WatchedService] Simkl sync request sent for episode`);
|
|
}
|
|
|
|
// Store locally as "completed"
|
|
const episodeId = `${showId}:${season}:${episode}`;
|
|
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
|
|
|
|
return { success: true, syncedToTrakt };
|
|
} catch (error) {
|
|
logger.error('[WatchedService] Failed to mark episode as watched:', error);
|
|
return { success: false, syncedToTrakt: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark multiple episodes as watched (batch operation)
|
|
* @param showImdbId - The IMDb ID of the show
|
|
* @param showId - The Stremio ID of the show (for local storage)
|
|
* @param episodes - Array of { season, episode } objects
|
|
* @param watchedAt - Optional date when watched
|
|
*/
|
|
public async markEpisodesAsWatched(
|
|
showImdbId: string,
|
|
showId: string,
|
|
episodes: Array<{ season: number; episode: number }>,
|
|
watchedAt: Date = new Date()
|
|
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
|
try {
|
|
if (episodes.length === 0) {
|
|
return { success: true, syncedToTrakt: false, count: 0 };
|
|
}
|
|
|
|
logger.log(`[WatchedService] Marking ${episodes.length} episodes as watched for ${showImdbId}`);
|
|
|
|
// Check if Trakt is authenticated
|
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
|
let syncedToTrakt = false;
|
|
|
|
if (isTraktAuth) {
|
|
// Sync to Trakt (batch operation)
|
|
syncedToTrakt = await this.traktService.markEpisodesAsWatched(
|
|
showImdbId,
|
|
episodes,
|
|
watchedAt
|
|
);
|
|
logger.log(`[WatchedService] Trakt batch sync result: ${syncedToTrakt}`);
|
|
}
|
|
|
|
// Sync to Simkl
|
|
const isSimklAuth = await this.simklService.isAuthenticated();
|
|
if (isSimklAuth) {
|
|
// Group by season for Simkl payload efficiency
|
|
const seasonMap = new Map<number, any[]>();
|
|
episodes.forEach(ep => {
|
|
if (!seasonMap.has(ep.season)) seasonMap.set(ep.season, []);
|
|
seasonMap.get(ep.season)?.push({ number: ep.episode, watched_at: watchedAt.toISOString() });
|
|
});
|
|
|
|
const seasons = Array.from(seasonMap.entries()).map(([num, eps]) => ({ number: num, episodes: eps }));
|
|
|
|
await this.simklService.addToHistory({
|
|
shows: [{
|
|
ids: { imdb: showImdbId },
|
|
seasons: seasons
|
|
}]
|
|
});
|
|
logger.log(`[WatchedService] Simkl batch sync request sent`);
|
|
}
|
|
|
|
// Store locally as "completed" for each episode
|
|
for (const ep of episodes) {
|
|
const episodeId = `${showId}:${ep.season}:${ep.episode}`;
|
|
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
|
|
}
|
|
|
|
return { success: true, syncedToTrakt, count: episodes.length };
|
|
} catch (error) {
|
|
logger.error('[WatchedService] Failed to mark episodes as watched:', error);
|
|
return { success: false, syncedToTrakt: false, count: 0 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark an entire season as watched
|
|
* @param showImdbId - The IMDb ID of the show
|
|
* @param showId - The Stremio ID of the show (for local storage)
|
|
* @param season - Season number
|
|
* @param episodeNumbers - Array of episode numbers in the season
|
|
* @param watchedAt - Optional date when watched
|
|
*/
|
|
public async markSeasonAsWatched(
|
|
showImdbId: string,
|
|
showId: string,
|
|
season: number,
|
|
episodeNumbers: number[],
|
|
watchedAt: Date = new Date()
|
|
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
|
try {
|
|
logger.log(`[WatchedService] Marking season ${season} as watched for ${showImdbId}`);
|
|
|
|
// Check if Trakt is authenticated
|
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
|
let syncedToTrakt = false;
|
|
|
|
if (isTraktAuth) {
|
|
// Sync entire season to Trakt
|
|
syncedToTrakt = await this.traktService.markSeasonAsWatched(
|
|
showImdbId,
|
|
season,
|
|
watchedAt
|
|
);
|
|
logger.log(`[WatchedService] Trakt season sync result: ${syncedToTrakt}`);
|
|
}
|
|
|
|
// Sync to Simkl
|
|
const isSimklAuth = await this.simklService.isAuthenticated();
|
|
if (isSimklAuth) {
|
|
// Simkl doesn't have a direct "mark season" generic endpoint in the same way, but we can construct it
|
|
// We know the episodeNumbers from the arguments!
|
|
const episodes = episodeNumbers.map(num => ({ number: num, watched_at: watchedAt.toISOString() }));
|
|
await this.simklService.addToHistory({
|
|
shows: [{
|
|
ids: { imdb: showImdbId },
|
|
seasons: [{
|
|
number: season,
|
|
episodes: episodes
|
|
}]
|
|
}]
|
|
});
|
|
logger.log(`[WatchedService] Simkl season sync request sent`);
|
|
}
|
|
|
|
// Store locally as "completed" for each episode in the season
|
|
for (const epNum of episodeNumbers) {
|
|
const episodeId = `${showId}:${season}:${epNum}`;
|
|
await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt);
|
|
}
|
|
|
|
return { success: true, syncedToTrakt, count: episodeNumbers.length };
|
|
} catch (error) {
|
|
logger.error('[WatchedService] Failed to mark season as watched:', error);
|
|
return { success: false, syncedToTrakt: false, count: 0 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unmark a movie as watched (remove from history)
|
|
*/
|
|
public async unmarkMovieAsWatched(
|
|
imdbId: string
|
|
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
|
try {
|
|
logger.log(`[WatchedService] Unmarking movie as watched: ${imdbId}`);
|
|
|
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
|
let syncedToTrakt = false;
|
|
|
|
if (isTraktAuth) {
|
|
syncedToTrakt = await this.traktService.removeMovieFromHistory(imdbId);
|
|
logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`);
|
|
}
|
|
|
|
// Simkl Unmark
|
|
const isSimklAuth = await this.simklService.isAuthenticated();
|
|
if (isSimklAuth) {
|
|
await this.simklService.removeFromHistory({ movies: [{ ids: { imdb: imdbId } }] });
|
|
logger.log(`[WatchedService] Simkl remove request sent for movie`);
|
|
}
|
|
|
|
// Remove local progress
|
|
await storageService.removeWatchProgress(imdbId, 'movie');
|
|
await mmkvStorage.removeItem(`watched:movie:${imdbId}`);
|
|
|
|
return { success: true, syncedToTrakt };
|
|
} catch (error) {
|
|
logger.error('[WatchedService] Failed to unmark movie as watched:', error);
|
|
return { success: false, syncedToTrakt: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unmark an episode as watched (remove from history)
|
|
*/
|
|
public async unmarkEpisodeAsWatched(
|
|
showImdbId: string,
|
|
showId: string,
|
|
season: number,
|
|
episode: number
|
|
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
|
|
try {
|
|
logger.log(`[WatchedService] Unmarking episode as watched: ${showImdbId} S${season}E${episode}`);
|
|
|
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
|
let syncedToTrakt = false;
|
|
|
|
if (isTraktAuth) {
|
|
syncedToTrakt = await this.traktService.removeEpisodeFromHistory(
|
|
showImdbId,
|
|
season,
|
|
episode
|
|
);
|
|
logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`);
|
|
}
|
|
|
|
// Simkl Unmark
|
|
const isSimklAuth = await this.simklService.isAuthenticated();
|
|
if (isSimklAuth) {
|
|
await this.simklService.removeFromHistory({
|
|
shows: [{
|
|
ids: { imdb: showImdbId },
|
|
seasons: [{
|
|
number: season,
|
|
episodes: [{ number: episode }]
|
|
}]
|
|
}]
|
|
});
|
|
logger.log(`[WatchedService] Simkl remove request sent for episode`);
|
|
}
|
|
|
|
// Remove local progress
|
|
const episodeId = `${showId}:${season}:${episode}`;
|
|
await storageService.removeWatchProgress(showId, 'series', episodeId);
|
|
|
|
return { success: true, syncedToTrakt };
|
|
} catch (error) {
|
|
logger.error('[WatchedService] Failed to unmark episode as watched:', error);
|
|
return { success: false, syncedToTrakt: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unmark an entire season as watched (remove from history)
|
|
* @param showImdbId - The IMDb ID of the show
|
|
* @param showId - The Stremio ID of the show (for local storage)
|
|
* @param season - Season number
|
|
* @param episodeNumbers - Array of episode numbers in the season
|
|
*/
|
|
public async unmarkSeasonAsWatched(
|
|
showImdbId: string,
|
|
showId: string,
|
|
season: number,
|
|
episodeNumbers: number[]
|
|
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
|
|
try {
|
|
logger.log(`[WatchedService] Unmarking season ${season} as watched for ${showImdbId}`);
|
|
|
|
const isTraktAuth = await this.traktService.isAuthenticated();
|
|
let syncedToTrakt = false;
|
|
|
|
if (isTraktAuth) {
|
|
// Remove entire season from Trakt
|
|
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
|
|
showImdbId,
|
|
season
|
|
);
|
|
syncedToTrakt = await this.traktService.removeSeasonFromHistory(
|
|
showImdbId,
|
|
season
|
|
);
|
|
logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`);
|
|
}
|
|
|
|
// Sync to Simkl
|
|
const isSimklAuth = await this.simklService.isAuthenticated();
|
|
if (isSimklAuth) {
|
|
const episodes = episodeNumbers.map(num => ({ number: num }));
|
|
await this.simklService.removeFromHistory({
|
|
shows: [{
|
|
ids: { imdb: showImdbId },
|
|
seasons: [{
|
|
number: season,
|
|
episodes: episodes
|
|
}]
|
|
}]
|
|
});
|
|
logger.log(`[WatchedService] Simkl season removal request sent`);
|
|
}
|
|
|
|
// Remove local progress for each episode in the season
|
|
for (const epNum of episodeNumbers) {
|
|
const episodeId = `${showId}:${season}:${epNum}`;
|
|
await storageService.removeWatchProgress(showId, 'series', episodeId);
|
|
}
|
|
|
|
return { success: true, syncedToTrakt, count: episodeNumbers.length };
|
|
} catch (error) {
|
|
logger.error('[WatchedService] Failed to unmark season as watched:', error);
|
|
return { success: false, syncedToTrakt: false, count: 0 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a movie is marked as watched (locally)
|
|
*/
|
|
public async isMovieWatched(imdbId: string): Promise<boolean> {
|
|
try {
|
|
const isAuthed = await this.traktService.isAuthenticated();
|
|
|
|
if (isAuthed) {
|
|
const traktWatched =
|
|
await this.traktService.isMovieWatchedAccurate(imdbId);
|
|
if (traktWatched) return true;
|
|
}
|
|
|
|
const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`);
|
|
return local === 'true';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Check if an episode is marked as watched (locally)
|
|
*/
|
|
public async isEpisodeWatched(
|
|
showId: string,
|
|
season: number,
|
|
episode: number
|
|
): Promise<boolean> {
|
|
try {
|
|
const isAuthed = await this.traktService.isAuthenticated();
|
|
|
|
if (isAuthed) {
|
|
const traktWatched =
|
|
await this.traktService.isEpisodeWatchedAccurate(
|
|
showId,
|
|
season,
|
|
episode
|
|
);
|
|
if (traktWatched) return true;
|
|
}
|
|
|
|
const episodeId = `${showId}:${season}:${episode}`;
|
|
const progress = await storageService.getWatchProgress(
|
|
showId,
|
|
'series',
|
|
episodeId
|
|
);
|
|
|
|
if (!progress) return false;
|
|
|
|
const pct = (progress.currentTime / progress.duration) * 100;
|
|
return pct >= 99;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set local watched status by creating a "completed" progress entry
|
|
*/
|
|
private async setLocalWatchedStatus(
|
|
id: string,
|
|
type: 'movie' | 'series',
|
|
watched: boolean,
|
|
episodeId?: string,
|
|
watchedAt: Date = new Date()
|
|
): Promise<void> {
|
|
try {
|
|
if (watched) {
|
|
// Create a "completed" progress entry (100% watched)
|
|
const progress = {
|
|
currentTime: 1, // Minimal values to indicate completion
|
|
duration: 1,
|
|
lastUpdated: watchedAt.getTime(),
|
|
traktSynced: false, // Will be set to true if Trakt sync succeeded
|
|
traktProgress: 100,
|
|
};
|
|
await storageService.setWatchProgress(id, type, progress, episodeId, {
|
|
forceWrite: true,
|
|
forceNotify: true
|
|
});
|
|
|
|
// Also set the legacy watched flag for movies
|
|
if (type === 'movie') {
|
|
await mmkvStorage.setItem(`watched:${type}:${id}`, 'true');
|
|
}
|
|
} else {
|
|
// Remove progress
|
|
await storageService.removeWatchProgress(id, type, episodeId);
|
|
if (type === 'movie') {
|
|
await mmkvStorage.removeItem(`watched:${type}:${id}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error('[WatchedService] Error setting local watched status:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
export const watchedService = WatchedService.getInstance();
|