mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
Merge branch 'NuvioMedia:main' into localization-patch
This commit is contained in:
commit
f5c1219550
14 changed files with 806 additions and 709 deletions
|
|
@ -10,3 +10,12 @@ export const CACHE_DURATION = 5 * 60 * 1000;
|
|||
export const TRAKT_SYNC_COOLDOWN = 0;
|
||||
export const SIMKL_SYNC_COOLDOWN = 0;
|
||||
export const TRAKT_RECONCILE_COOLDOWN = 0;
|
||||
|
||||
// Match NuvioTV: 60-day window (was 30), 300 max items (was 30), 24 max next-up lookups
|
||||
export const CW_DEFAULT_DAYS_CAP = 60;
|
||||
export const CW_MAX_RECENT_PROGRESS_ITEMS = 300;
|
||||
export const CW_MAX_NEXT_UP_LOOKUPS = 24;
|
||||
export const CW_MAX_DISPLAY_ITEMS = 30;
|
||||
export const CW_NEXT_UP_NEW_SEASON_UNAIRED_WINDOW_DAYS = 7;
|
||||
export const CW_HISTORY_MAX_PAGES = 5;
|
||||
export const CW_HISTORY_PAGE_LIMIT = 100;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { storageService } from '../../../services/storageService';
|
|||
import { stremioService } from '../../../services/stremioService';
|
||||
import { TraktContentData } from '../../../services/traktService';
|
||||
|
||||
import { CACHE_DURATION } from './constants';
|
||||
import { CACHE_DURATION, CW_NEXT_UP_NEW_SEASON_UNAIRED_WINDOW_DAYS } from './constants';
|
||||
import {
|
||||
CachedMetadataEntry,
|
||||
GetCachedMetadata,
|
||||
|
|
@ -133,7 +133,8 @@ export function findNextEpisode(
|
|||
watchedSet?: Set<string>,
|
||||
showId?: string,
|
||||
localWatchedMap?: Map<string, number>,
|
||||
baseTimestamp: number = 0
|
||||
baseTimestamp: number = 0,
|
||||
showUnairedNextUp: boolean = true
|
||||
): { video: any; lastWatched: number } | null {
|
||||
if (!videos || !Array.isArray(videos)) return null;
|
||||
|
||||
|
|
@ -170,12 +171,48 @@ export function findNextEpisode(
|
|||
return false;
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const todayMs = now.getTime();
|
||||
|
||||
for (const video of sortedVideos) {
|
||||
if (video.season < currentSeason) continue;
|
||||
if (video.season === currentSeason && video.episode <= currentEpisode) continue;
|
||||
if (isAlreadyWatched(video.season, video.episode)) continue;
|
||||
|
||||
if (isEpisodeReleased(video)) {
|
||||
const isSeasonRollover = video.season !== currentSeason;
|
||||
const releaseDate = video.released ? new Date(video.released) : null;
|
||||
const isValidDate = releaseDate && !isNaN(releaseDate.getTime());
|
||||
|
||||
if (isSeasonRollover) {
|
||||
// Match NuvioTV: for season rollovers, require a valid release date
|
||||
if (!isValidDate) continue;
|
||||
|
||||
if (releaseDate!.getTime() <= todayMs) {
|
||||
// Already aired — include it
|
||||
return { video, lastWatched: latestWatchedTimestamp };
|
||||
}
|
||||
|
||||
if (!showUnairedNextUp) continue;
|
||||
|
||||
// Only show unaired next-season episodes within 7-day window
|
||||
const daysUntil = Math.ceil((releaseDate!.getTime() - todayMs) / (24 * 60 * 60 * 1000));
|
||||
if (daysUntil <= CW_NEXT_UP_NEW_SEASON_UNAIRED_WINDOW_DAYS) {
|
||||
return { video, lastWatched: latestWatchedTimestamp };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Same season
|
||||
if (isValidDate && releaseDate!.getTime() > todayMs) {
|
||||
// Unaired same-season episode
|
||||
if (showUnairedNextUp) {
|
||||
return { video, lastWatched: latestWatchedTimestamp };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aired or no date (same season) — include it
|
||||
if (isEpisodeReleased(video) || !video.released) {
|
||||
return { video, lastWatched: latestWatchedTimestamp };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,18 @@ import {
|
|||
} from '../../../services/traktService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
import { TRAKT_RECONCILE_COOLDOWN, TRAKT_SYNC_COOLDOWN } from './constants';
|
||||
import {
|
||||
CW_DEFAULT_DAYS_CAP,
|
||||
CW_HISTORY_MAX_PAGES,
|
||||
CW_HISTORY_PAGE_LIMIT,
|
||||
CW_MAX_DISPLAY_ITEMS,
|
||||
CW_MAX_NEXT_UP_LOOKUPS,
|
||||
CW_MAX_RECENT_PROGRESS_ITEMS,
|
||||
TRAKT_RECONCILE_COOLDOWN,
|
||||
TRAKT_SYNC_COOLDOWN,
|
||||
} from './constants';
|
||||
import { GetCachedMetadata, LocalProgressEntry } from './dataTypes';
|
||||
import {
|
||||
buildTraktContentData,
|
||||
filterRemovedItems,
|
||||
findNextEpisode,
|
||||
getHighestLocalMatch,
|
||||
|
|
@ -18,7 +26,6 @@ import {
|
|||
getMostRecentLocalMatch,
|
||||
} from './dataShared';
|
||||
import { ContinueWatchingItem } from './types';
|
||||
import { compareContinueWatchingItems } from './utils';
|
||||
|
||||
interface MergeTraktContinueWatchingParams {
|
||||
traktService: TraktService;
|
||||
|
|
@ -31,6 +38,23 @@ interface MergeTraktContinueWatchingParams {
|
|||
setContinueWatchingItems: Dispatch<SetStateAction<ContinueWatchingItem[]>>;
|
||||
}
|
||||
|
||||
// CHANGE: Added bulletproof time parser to prevent NaN from breaking sort algorithm.
|
||||
// Previously used `new Date(value).getTime()` inline which could produce NaN and
|
||||
// cause unpredictable sort order.
|
||||
const getValidTime = (dateVal: any): number => {
|
||||
if (!dateVal) return 0;
|
||||
if (typeof dateVal === 'number') return isNaN(dateVal) ? 0 : dateVal;
|
||||
if (typeof dateVal === 'string') {
|
||||
const parsed = new Date(dateVal).getTime();
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
if (dateVal instanceof Date) {
|
||||
const parsed = dateVal.getTime();
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export async function mergeTraktContinueWatching({
|
||||
traktService,
|
||||
getCachedMetadata,
|
||||
|
|
@ -41,6 +65,16 @@ export async function mergeTraktContinueWatching({
|
|||
lastTraktReconcileRef,
|
||||
setContinueWatchingItems,
|
||||
}: MergeTraktContinueWatchingParams): Promise<void> {
|
||||
|
||||
// CHANGE: Added auth check at the top. If user is not authenticated,
|
||||
// clear the list immediately and return. The `await` is required —
|
||||
// without it isAuthenticated() returns a Promise (always truthy) and
|
||||
// the check never fires.
|
||||
if (!await traktService.isAuthenticated()) {
|
||||
setContinueWatchingItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (
|
||||
TRAKT_SYNC_COOLDOWN > 0 &&
|
||||
|
|
@ -53,177 +87,311 @@ export async function mergeTraktContinueWatching({
|
|||
}
|
||||
|
||||
lastTraktSyncRef.current = now;
|
||||
const playbackItems = await traktService.getPlaybackProgress();
|
||||
const traktBatch: ContinueWatchingItem[] = [];
|
||||
|
||||
// ─── 1. Fetch all Trakt data sources (matching NuvioTV) ───
|
||||
let playbackItems: any[] = [];
|
||||
let watchedShowsData: TraktWatchedItem[] = [];
|
||||
let episodeHistoryItems: any[] = [];
|
||||
|
||||
try {
|
||||
const [playbackResult, watchedResult] = await Promise.all([
|
||||
traktService.getPlaybackProgress(),
|
||||
traktService.getWatchedShows(),
|
||||
]);
|
||||
playbackItems = playbackResult;
|
||||
watchedShowsData = watchedResult;
|
||||
logger.log(`[TraktCW] Fetched ${playbackItems?.length ?? 0} playback items, ${watchedShowsData?.length ?? 0} watched shows`);
|
||||
} catch (err) {
|
||||
logger.warn('[TraktSync] API failed (likely disconnected or expired token):', err);
|
||||
setContinueWatchingItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch episode history (matching NuvioTV's fetchRecentEpisodeHistorySnapshot)
|
||||
try {
|
||||
const historyResults: any[] = [];
|
||||
const seenContentIds = new Set<string>();
|
||||
for (let page = 1; page <= CW_HISTORY_MAX_PAGES; page++) {
|
||||
const pageItems = await traktService.getWatchedEpisodesHistory(page, CW_HISTORY_PAGE_LIMIT);
|
||||
if (!pageItems || pageItems.length === 0) break;
|
||||
|
||||
for (const item of pageItems) {
|
||||
const showImdb = item.show?.ids?.imdb;
|
||||
if (!showImdb) continue;
|
||||
const normalizedId = showImdb.startsWith('tt') ? showImdb : `tt${showImdb}`;
|
||||
// NuvioTV deduplicates by contentId (one per show), keeping the most recent
|
||||
if (seenContentIds.has(normalizedId)) continue;
|
||||
seenContentIds.add(normalizedId);
|
||||
historyResults.push(item);
|
||||
if (historyResults.length >= CW_MAX_RECENT_PROGRESS_ITEMS) break;
|
||||
}
|
||||
|
||||
if (historyResults.length >= CW_MAX_RECENT_PROGRESS_ITEMS) break;
|
||||
if (pageItems.length < CW_HISTORY_PAGE_LIMIT) break;
|
||||
}
|
||||
episodeHistoryItems = historyResults;
|
||||
logger.log(`[TraktCW] Fetched ${episodeHistoryItems.length} episode history items (unique shows)`);
|
||||
} catch (err) {
|
||||
logger.warn('[TraktSync] Failed to fetch episode history:', err);
|
||||
}
|
||||
|
||||
// ─── 2. Build watched episode sets per show ───
|
||||
const watchedEpisodeSetByShow = new Map<string, Set<string>>();
|
||||
|
||||
try {
|
||||
watchedShowsData = await traktService.getWatchedShows();
|
||||
for (const watchedShow of watchedShowsData) {
|
||||
if (!watchedShow.show?.ids?.imdb) continue;
|
||||
|
||||
const imdb = watchedShow.show.ids.imdb.startsWith('tt')
|
||||
? watchedShow.show.ids.imdb
|
||||
: `tt${watchedShow.show.ids.imdb}`;
|
||||
const resetAt = watchedShow.reset_at ? new Date(watchedShow.reset_at).getTime() : 0;
|
||||
|
||||
const resetAt = getValidTime(watchedShow.reset_at);
|
||||
const episodeSet = new Set<string>();
|
||||
|
||||
if (watchedShow.seasons) {
|
||||
for (const season of watchedShow.seasons) {
|
||||
for (const episode of season.episodes) {
|
||||
if (resetAt > 0) {
|
||||
const watchedAt = new Date(episode.last_watched_at).getTime();
|
||||
const watchedAt = getValidTime(episode.last_watched_at);
|
||||
if (watchedAt < resetAt) continue;
|
||||
}
|
||||
|
||||
episodeSet.add(`${imdb}:${season.number}:${episode.number}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watchedEpisodeSetByShow.set(imdb, episodeSet);
|
||||
}
|
||||
} catch {
|
||||
// Continue without watched-show acceleration.
|
||||
} catch (err) {
|
||||
logger.warn('[TraktSync] Error mapping watched shows:', err);
|
||||
}
|
||||
|
||||
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
|
||||
const sortedPlaybackItems = [...playbackItems]
|
||||
.sort((a, b) => {
|
||||
const getBaseTime = (item: any) =>
|
||||
new Date(
|
||||
item.paused_at ||
|
||||
item.updated_at ||
|
||||
item.last_watched_at ||
|
||||
0
|
||||
).getTime();
|
||||
// ─── 3. Merge sources: history first, then playback overwrites (matching NuvioTV) ───
|
||||
// NuvioTV merges in order: recentCompletedEpisodes → (inProgressMovies + inProgressEpisodes)
|
||||
// Later entries overwrite earlier ones by key, so playback (in-progress) takes priority.
|
||||
|
||||
const getPriorityTime = (item: any) => {
|
||||
const base = getBaseTime(item);
|
||||
// NEW EPISODE PRIORITY BOOST
|
||||
if (item.episode && (item.progress ?? 0) < 1) {
|
||||
const aired = new Date(item.episode.first_aired || 0).getTime();
|
||||
const daysSinceAired = (Date.now() - aired) / (1000 * 60 * 60 * 24);
|
||||
if (daysSinceAired >= 0 && daysSinceAired < 60) {
|
||||
return base + 1000000000; // boost to top on aired ep
|
||||
}
|
||||
}
|
||||
return base;
|
||||
};
|
||||
const daysCutoff = Date.now() - (CW_DEFAULT_DAYS_CAP * 24 * 60 * 60 * 1000);
|
||||
|
||||
return getPriorityTime(b) - getPriorityTime(a);
|
||||
})
|
||||
.slice(0, 30);
|
||||
// Internal progress items keyed by "type:contentId" for series or "type:contentId" for movies
|
||||
interface ProgressEntry {
|
||||
contentId: string;
|
||||
contentType: 'movie' | 'series';
|
||||
season?: number;
|
||||
episode?: number;
|
||||
episodeTitle?: string;
|
||||
progressPercent: number; // 0-100
|
||||
lastWatched: number;
|
||||
source: 'playback' | 'history' | 'watched_show';
|
||||
traktPlaybackId?: number;
|
||||
}
|
||||
|
||||
const mergedByKey = new Map<string, ProgressEntry>();
|
||||
|
||||
// 3a. Episode history items (completed episodes) — go in first, can be overwritten by playback
|
||||
for (const item of episodeHistoryItems) {
|
||||
try {
|
||||
const show = item.show;
|
||||
const episode = item.episode;
|
||||
if (!show?.ids?.imdb || !episode) continue;
|
||||
|
||||
const showImdb = show.ids.imdb.startsWith('tt')
|
||||
? show.ids.imdb
|
||||
: `tt${show.ids.imdb}`;
|
||||
const lastWatched = getValidTime(item.watched_at);
|
||||
if (lastWatched > 0 && lastWatched < daysCutoff) continue;
|
||||
|
||||
const key = showImdb; // NuvioTV uses contentId as key (one per show)
|
||||
mergedByKey.set(key, {
|
||||
contentId: showImdb,
|
||||
contentType: 'series',
|
||||
season: episode.season,
|
||||
episode: episode.number,
|
||||
episodeTitle: episode.title,
|
||||
progressPercent: 100, // Completed
|
||||
lastWatched,
|
||||
source: 'history',
|
||||
});
|
||||
} catch {
|
||||
// Skip bad items
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Playback items (in-progress) — overwrite history entries for the same content
|
||||
const sortedPlaybackItems = [...(playbackItems || [])]
|
||||
.sort((a, b) => {
|
||||
const timeA = getValidTime(a.paused_at || a.updated_at || a.last_watched_at);
|
||||
const timeB = getValidTime(b.paused_at || b.updated_at || b.last_watched_at);
|
||||
return timeB - timeA;
|
||||
})
|
||||
.slice(0, CW_MAX_RECENT_PROGRESS_ITEMS);
|
||||
|
||||
for (const item of sortedPlaybackItems) {
|
||||
try {
|
||||
if (item.progress < 2) continue;
|
||||
|
||||
const pausedAt = new Date(item.paused_at).getTime();
|
||||
if (pausedAt < thirtyDaysAgo) continue;
|
||||
const pausedAt = getValidTime(item.paused_at || item.updated_at);
|
||||
if (pausedAt > 0 && pausedAt < daysCutoff) continue;
|
||||
|
||||
if (item.type === 'movie' && item.movie?.ids?.imdb) {
|
||||
if (item.progress >= 85) continue;
|
||||
|
||||
const imdbId = item.movie.ids.imdb.startsWith('tt')
|
||||
? item.movie.ids.imdb
|
||||
: `tt${item.movie.ids.imdb}`;
|
||||
|
||||
if (recentlyRemoved.has(`movie:${imdbId}`)) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('movie', imdbId);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
||||
traktBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: imdbId,
|
||||
type: 'movie',
|
||||
progress: item.progress,
|
||||
lastUpdated: pausedAt,
|
||||
addonId: undefined,
|
||||
const key = imdbId;
|
||||
mergedByKey.set(key, {
|
||||
contentId: imdbId,
|
||||
contentType: 'movie',
|
||||
progressPercent: item.progress,
|
||||
lastWatched: pausedAt,
|
||||
source: 'playback',
|
||||
traktPlaybackId: item.id,
|
||||
} as ContinueWatchingItem);
|
||||
});
|
||||
} else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) {
|
||||
const showImdb = item.show.ids.imdb.startsWith('tt')
|
||||
? item.show.ids.imdb
|
||||
: `tt${item.show.ids.imdb}`;
|
||||
|
||||
if (recentlyRemoved.has(`series:${showImdb}`)) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('series', showImdb);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
||||
if (item.progress >= 85) {
|
||||
if (cachedData.metadata?.videos) {
|
||||
const watchedSetForShow = watchedEpisodeSetByShow.get(showImdb);
|
||||
const localWatchedMap = await localWatchedShowsMapPromise;
|
||||
const nextEpisodeResult = findNextEpisode(
|
||||
item.episode.season,
|
||||
item.episode.number,
|
||||
cachedData.metadata.videos,
|
||||
watchedSetForShow,
|
||||
showImdb,
|
||||
localWatchedMap,
|
||||
pausedAt
|
||||
);
|
||||
|
||||
if (nextEpisodeResult) {
|
||||
const nextEpisode = nextEpisodeResult.video;
|
||||
traktBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: 0,
|
||||
lastUpdated: nextEpisodeResult.lastWatched,
|
||||
season: nextEpisode.season,
|
||||
episode: nextEpisode.episode,
|
||||
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
|
||||
addonId: undefined,
|
||||
traktPlaybackId: item.id,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
traktBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: item.progress,
|
||||
lastUpdated: pausedAt,
|
||||
const key = showImdb;
|
||||
mergedByKey.set(key, {
|
||||
contentId: showImdb,
|
||||
contentType: 'series',
|
||||
season: item.episode.season,
|
||||
episode: item.episode.number,
|
||||
episodeTitle: item.episode.title || `Episode ${item.episode.number}`,
|
||||
addonId: undefined,
|
||||
episodeTitle: item.episode.title,
|
||||
progressPercent: item.progress,
|
||||
lastWatched: pausedAt,
|
||||
source: 'playback',
|
||||
traktPlaybackId: item.id,
|
||||
} as ContinueWatchingItem);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Continue with remaining playback items.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000);
|
||||
// ─── 4. Sort merged items by lastWatched and apply cap ───
|
||||
const allMerged = Array.from(mergedByKey.values())
|
||||
.sort((a, b) => b.lastWatched - a.lastWatched)
|
||||
.slice(0, CW_MAX_RECENT_PROGRESS_ITEMS);
|
||||
|
||||
for (const watchedShow of watchedShowsData) {
|
||||
logger.log(`[TraktCW] Merged ${allMerged.length} items (history→playback). Breakdown: ${allMerged.filter(e => e.source === 'history').length} history, ${allMerged.filter(e => e.source === 'playback').length} playback`);
|
||||
for (const entry of allMerged.slice(0, 15)) {
|
||||
logger.log(`[TraktCW] ${entry.contentType} ${entry.contentId} S${entry.season ?? '-'}E${entry.episode ?? '-'} progress=${entry.progressPercent.toFixed(1)}% src=${entry.source} last=${new Date(entry.lastWatched).toISOString()}`);
|
||||
}
|
||||
if (allMerged.length > 15) logger.log(`[TraktCW] ... and ${allMerged.length - 15} more`);
|
||||
|
||||
// ─── 5. Separate in-progress items vs completed seeds (matching NuvioTV pipeline) ───
|
||||
// In-progress: 2% ≤ progress < 85%
|
||||
// Completed seed: progress ≥ 85% (will be used for Up Next)
|
||||
const inProgressEntries: ProgressEntry[] = [];
|
||||
const completedSeeds: ProgressEntry[] = [];
|
||||
|
||||
for (const entry of allMerged) {
|
||||
if (entry.progressPercent >= 2 && entry.progressPercent < 85) {
|
||||
inProgressEntries.push(entry);
|
||||
} else if (entry.progressPercent >= 85) {
|
||||
completedSeeds.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(`[TraktCW] Separated: ${inProgressEntries.length} in-progress (2-85%), ${completedSeeds.length} completed seeds (≥85%)`);
|
||||
|
||||
// ─── 6. Episode deduplication for in-progress (matching NuvioTV deduplicateInProgress) ───
|
||||
// For series: only keep the latest-watched episode per series
|
||||
const dedupedInProgress: ProgressEntry[] = [];
|
||||
const seriesLatest = new Map<string, ProgressEntry>();
|
||||
|
||||
for (const entry of inProgressEntries) {
|
||||
if (entry.contentType === 'series') {
|
||||
const existing = seriesLatest.get(entry.contentId);
|
||||
if (!existing || entry.lastWatched > existing.lastWatched) {
|
||||
seriesLatest.set(entry.contentId, entry);
|
||||
}
|
||||
} else {
|
||||
dedupedInProgress.push(entry);
|
||||
}
|
||||
}
|
||||
dedupedInProgress.push(...seriesLatest.values());
|
||||
dedupedInProgress.sort((a, b) => b.lastWatched - a.lastWatched);
|
||||
|
||||
logger.log(`[TraktCW] After series dedup: ${dedupedInProgress.length} in-progress items (was ${inProgressEntries.length})`);
|
||||
for (const entry of dedupedInProgress) {
|
||||
logger.log(`[TraktCW] IN-PROGRESS: ${entry.contentType} ${entry.contentId} S${entry.season ?? '-'}E${entry.episode ?? '-'} progress=${entry.progressPercent.toFixed(1)}% last=${new Date(entry.lastWatched).toISOString()}`);
|
||||
}
|
||||
|
||||
// ─── 7. Build in-progress ContinueWatchingItems ───
|
||||
const traktBatch: ContinueWatchingItem[] = [];
|
||||
const inProgressSeriesIds = new Set<string>();
|
||||
|
||||
for (const entry of dedupedInProgress) {
|
||||
if (recentlyRemoved.has(`${entry.contentType}:${entry.contentId}`)) continue;
|
||||
|
||||
const type = entry.contentType === 'movie' ? 'movie' : 'series';
|
||||
const cachedData = await getCachedMetadata(type, entry.contentId);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
||||
if (entry.contentType === 'series') {
|
||||
inProgressSeriesIds.add(entry.contentId);
|
||||
}
|
||||
|
||||
traktBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: entry.contentId,
|
||||
type: type,
|
||||
progress: entry.progressPercent,
|
||||
lastUpdated: entry.lastWatched,
|
||||
season: entry.season,
|
||||
episode: entry.episode,
|
||||
episodeTitle: entry.episodeTitle || (entry.episode ? `Episode ${entry.episode}` : undefined),
|
||||
addonId: undefined,
|
||||
traktPlaybackId: entry.traktPlaybackId,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
|
||||
logger.log(`[TraktCW] Built ${traktBatch.length} in-progress CW items. Suppressed series IDs: [${Array.from(inProgressSeriesIds).join(', ')}]`);
|
||||
|
||||
// ─── 8. Build Up Next items from completed seeds (matching NuvioTV buildLightweightNextUpItems) ───
|
||||
// Completed seeds from playback + history: find next episode for each
|
||||
const nextUpSeeds: ProgressEntry[] = [];
|
||||
|
||||
// Add completed entries from merged data
|
||||
for (const entry of completedSeeds) {
|
||||
if (entry.contentType !== 'series') continue;
|
||||
if (inProgressSeriesIds.has(entry.contentId)) continue; // Next-up suppression
|
||||
if (recentlyRemoved.has(`series:${entry.contentId}`)) continue;
|
||||
nextUpSeeds.push(entry);
|
||||
}
|
||||
|
||||
// ─── 9. Add watched show seeds (matching NuvioTV observeWatchedShowSeeds) ───
|
||||
try {
|
||||
const sixMonthsAgo = Date.now() - (180 * 24 * 60 * 60 * 1000);
|
||||
const sortedWatchedShows = [...(watchedShowsData || [])]
|
||||
.filter((show) => {
|
||||
const watchedAt = getValidTime(show.last_watched_at);
|
||||
return watchedAt > sixMonthsAgo;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const timeA = getValidTime(a.last_watched_at);
|
||||
const timeB = getValidTime(b.last_watched_at);
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
for (const watchedShow of sortedWatchedShows) {
|
||||
try {
|
||||
if (!watchedShow.show?.ids?.imdb) continue;
|
||||
|
||||
const lastWatchedAt = new Date(watchedShow.last_watched_at).getTime();
|
||||
if (lastWatchedAt < thirtyDaysAgoForShows) continue;
|
||||
|
||||
const showImdb = watchedShow.show.ids.imdb.startsWith('tt')
|
||||
? watchedShow.show.ids.imdb
|
||||
: `tt${watchedShow.show.ids.imdb}`;
|
||||
|
||||
// Skip if already in in-progress (next-up suppression)
|
||||
if (inProgressSeriesIds.has(showImdb)) continue;
|
||||
if (recentlyRemoved.has(`series:${showImdb}`)) continue;
|
||||
|
||||
const resetAt = watchedShow.reset_at ? new Date(watchedShow.reset_at).getTime() : 0;
|
||||
// Skip if we already have a seed for this show (from playback/history)
|
||||
if (nextUpSeeds.some(s => s.contentId === showImdb)) continue;
|
||||
|
||||
const resetAt = getValidTime(watchedShow.reset_at);
|
||||
let lastWatchedSeason = 0;
|
||||
let lastWatchedEpisode = 0;
|
||||
let latestEpisodeTimestamp = 0;
|
||||
|
|
@ -231,7 +399,7 @@ export async function mergeTraktContinueWatching({
|
|||
if (watchedShow.seasons) {
|
||||
for (const season of watchedShow.seasons) {
|
||||
for (const episode of season.episodes) {
|
||||
const episodeTimestamp = new Date(episode.last_watched_at).getTime();
|
||||
const episodeTimestamp = getValidTime(episode.last_watched_at);
|
||||
if (resetAt > 0 && episodeTimestamp < resetAt) continue;
|
||||
|
||||
if (episodeTimestamp > latestEpisodeTimestamp) {
|
||||
|
|
@ -245,47 +413,118 @@ export async function mergeTraktContinueWatching({
|
|||
|
||||
if (lastWatchedSeason === 0 && lastWatchedEpisode === 0) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('series', showImdb);
|
||||
if (!cachedData?.basicContent || !cachedData.metadata?.videos) continue;
|
||||
|
||||
const watchedEpisodeSet = watchedEpisodeSetByShow.get(showImdb) ?? new Set<string>();
|
||||
const localWatchedMap = await localWatchedShowsMapPromise;
|
||||
const nextEpisodeResult = findNextEpisode(
|
||||
lastWatchedSeason,
|
||||
lastWatchedEpisode,
|
||||
cachedData.metadata.videos,
|
||||
watchedEpisodeSet,
|
||||
showImdb,
|
||||
localWatchedMap,
|
||||
latestEpisodeTimestamp
|
||||
);
|
||||
|
||||
if (nextEpisodeResult) {
|
||||
const nextEpisode = nextEpisodeResult.video;
|
||||
traktBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: 0,
|
||||
lastUpdated: nextEpisodeResult.lastWatched,
|
||||
season: nextEpisode.season,
|
||||
episode: nextEpisode.episode,
|
||||
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
|
||||
addonId: undefined,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
nextUpSeeds.push({
|
||||
contentId: showImdb,
|
||||
contentType: 'series',
|
||||
season: lastWatchedSeason,
|
||||
episode: lastWatchedEpisode,
|
||||
progressPercent: 100,
|
||||
lastWatched: latestEpisodeTimestamp,
|
||||
source: 'watched_show',
|
||||
});
|
||||
} catch {
|
||||
// Continue with remaining watched shows.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[TraktSync] Error fetching watched shows for Up Next:', err);
|
||||
logger.warn('[TraktSync] Error processing watched shows for Up Next:', err);
|
||||
}
|
||||
|
||||
// ─── 10. Choose preferred seed per show (matching NuvioTV choosePreferredNextUpSeed) ───
|
||||
// Source ranking: playback (0) > history (1) > watched_show (2)
|
||||
const seedSourceRank = (source: string): number => {
|
||||
switch (source) {
|
||||
case 'playback': return 0;
|
||||
case 'history': return 1;
|
||||
case 'watched_show': return 2;
|
||||
default: return 4;
|
||||
}
|
||||
};
|
||||
|
||||
const seedsByShow = new Map<string, ProgressEntry[]>();
|
||||
for (const seed of nextUpSeeds) {
|
||||
const existing = seedsByShow.get(seed.contentId) || [];
|
||||
existing.push(seed);
|
||||
seedsByShow.set(seed.contentId, existing);
|
||||
}
|
||||
|
||||
const bestSeeds: ProgressEntry[] = [];
|
||||
for (const [, seeds] of seedsByShow) {
|
||||
const bestRank = Math.min(...seeds.map(s => seedSourceRank(s.source)));
|
||||
const bestRanked = seeds.filter(s => seedSourceRank(s.source) === bestRank);
|
||||
// Among same-rank seeds, pick highest season/episode, then most recent
|
||||
bestRanked.sort((a, b) => {
|
||||
if ((a.season ?? -1) !== (b.season ?? -1)) return (b.season ?? -1) - (a.season ?? -1);
|
||||
if ((a.episode ?? -1) !== (b.episode ?? -1)) return (b.episode ?? -1) - (a.episode ?? -1);
|
||||
return b.lastWatched - a.lastWatched;
|
||||
});
|
||||
if (bestRanked.length > 0) bestSeeds.push(bestRanked[0]);
|
||||
}
|
||||
|
||||
// Sort by lastWatched and limit to CW_MAX_NEXT_UP_LOOKUPS (24)
|
||||
bestSeeds.sort((a, b) => b.lastWatched - a.lastWatched);
|
||||
const topSeeds = bestSeeds.slice(0, CW_MAX_NEXT_UP_LOOKUPS);
|
||||
|
||||
logger.log(`[TraktCW] Up Next seeds: ${nextUpSeeds.length} total → ${bestSeeds.length} deduped → ${topSeeds.length} top seeds`);
|
||||
for (const seed of topSeeds) {
|
||||
logger.log(`[TraktCW] SEED: ${seed.contentId} S${seed.season}E${seed.episode} src=${seed.source} rank=${seedSourceRank(seed.source)} last=${new Date(seed.lastWatched).toISOString()}`);
|
||||
}
|
||||
|
||||
// ─── 11. Resolve next episodes for each seed ───
|
||||
const localWatchedMap = await localWatchedShowsMapPromise;
|
||||
|
||||
for (const seed of topSeeds) {
|
||||
try {
|
||||
if (!seed.season || !seed.episode) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('series', seed.contentId);
|
||||
if (!cachedData?.basicContent || !cachedData.metadata?.videos) continue;
|
||||
|
||||
const watchedEpisodeSet = watchedEpisodeSetByShow.get(seed.contentId) ?? new Set<string>();
|
||||
const nextEpisodeResult = findNextEpisode(
|
||||
seed.season,
|
||||
seed.episode,
|
||||
cachedData.metadata.videos,
|
||||
watchedEpisodeSet,
|
||||
seed.contentId,
|
||||
localWatchedMap,
|
||||
seed.lastWatched,
|
||||
true // showUnairedNextUp
|
||||
);
|
||||
|
||||
if (nextEpisodeResult) {
|
||||
const nextEpisode = nextEpisodeResult.video;
|
||||
logger.log(`[TraktCW] UP-NEXT RESOLVED: ${seed.contentId} seed=S${seed.season}E${seed.episode} → next=S${nextEpisode.season}E${nextEpisode.episode} "${nextEpisode.title || ''}" last=${new Date(seed.lastWatched).toISOString()}`);
|
||||
traktBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: seed.contentId,
|
||||
type: 'series',
|
||||
progress: 0,
|
||||
lastUpdated: seed.lastWatched,
|
||||
season: nextEpisode.season,
|
||||
episode: nextEpisode.episode,
|
||||
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
|
||||
addonId: undefined,
|
||||
traktPlaybackId: seed.traktPlaybackId,
|
||||
} as ContinueWatchingItem);
|
||||
} else {
|
||||
logger.log(`[TraktCW] UP-NEXT DROPPED: ${seed.contentId} seed=S${seed.season}E${seed.episode} — no next episode found (no videos or all watched)`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[TraktCW] UP-NEXT ERROR: ${seed.contentId}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 12. Final dedup, reconcile, and sort ───
|
||||
logger.log(`[TraktCW] Pre-dedup batch: ${traktBatch.length} items (${traktBatch.filter(i => (i.progress ?? 0) > 0).length} in-progress + ${traktBatch.filter(i => (i.progress ?? 0) === 0).length} up-next)`);
|
||||
|
||||
if (traktBatch.length === 0) {
|
||||
logger.log('[TraktCW] No items — clearing continue watching list');
|
||||
setContinueWatchingItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate: for same content, prefer items with progress > 0 (in-progress over up-next)
|
||||
const deduped = new Map<string, ContinueWatchingItem>();
|
||||
for (const item of traktBatch) {
|
||||
const key = `${item.type}:${item.id}`;
|
||||
|
|
@ -299,25 +538,27 @@ export async function mergeTraktContinueWatching({
|
|||
const existingHasProgress = (existing.progress ?? 0) > 0;
|
||||
const candidateHasProgress = (item.progress ?? 0) > 0;
|
||||
|
||||
const safeItemTs = getValidTime(item.lastUpdated);
|
||||
const safeExistingTs = getValidTime(existing.lastUpdated);
|
||||
|
||||
if (candidateHasProgress && !existingHasProgress) {
|
||||
const mergedTs = Math.max(item.lastUpdated ?? 0, existing.lastUpdated ?? 0);
|
||||
const mergedTs = Math.max(safeItemTs, safeExistingTs);
|
||||
deduped.set(
|
||||
key,
|
||||
mergedTs !== (item.lastUpdated ?? 0)
|
||||
mergedTs !== safeItemTs
|
||||
? { ...item, lastUpdated: mergedTs }
|
||||
: item
|
||||
);
|
||||
} else if (!candidateHasProgress && existingHasProgress) {
|
||||
if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
|
||||
deduped.set(key, { ...existing, lastUpdated: item.lastUpdated });
|
||||
if (safeItemTs > safeExistingTs) {
|
||||
deduped.set(key, { ...existing, lastUpdated: safeItemTs });
|
||||
}
|
||||
} else if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
|
||||
} else if (safeItemTs > safeExistingTs) {
|
||||
deduped.set(key, item);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = await filterRemovedItems(Array.from(deduped.values()), recentlyRemoved);
|
||||
const reconcilePromises: Promise<any>[] = [];
|
||||
const reconcileLocalPromises: Promise<any>[] = [];
|
||||
|
||||
const adjustedItems = filteredItems
|
||||
|
|
@ -332,14 +573,14 @@ export async function mergeTraktContinueWatching({
|
|||
return item;
|
||||
}
|
||||
|
||||
const mergedLastUpdated = Math.max(
|
||||
mostRecentLocal.lastUpdated ?? 0,
|
||||
item.lastUpdated ?? 0
|
||||
);
|
||||
// Use getValidTime for safe timestamp extraction
|
||||
const safeLocalTs = getValidTime(mostRecentLocal.lastUpdated);
|
||||
const safeItemTs = getValidTime(item.lastUpdated);
|
||||
|
||||
const localProgress = mostRecentLocal.progressPercent;
|
||||
const traktProgress = item.progress ?? 0;
|
||||
const traktTs = item.lastUpdated ?? 0;
|
||||
const localTs = mostRecentLocal.lastUpdated ?? 0;
|
||||
const traktTs = safeItemTs;
|
||||
const localTs = safeLocalTs;
|
||||
|
||||
const isAhead = isFinite(localProgress) && localProgress > traktProgress + 0.5;
|
||||
const isLocalNewer = localTs > traktTs + 5000;
|
||||
|
|
@ -401,51 +642,49 @@ export async function mergeTraktContinueWatching({
|
|||
}
|
||||
}
|
||||
|
||||
if ((isAhead || ((isLocalNewer || isLocalRecent) && isDifferent)) && localProgress >= 2) {
|
||||
const reconcileKey = `${item.type}:${item.id}:${item.season ?? ''}:${item.episode ?? ''}`;
|
||||
const last = lastTraktReconcileRef.current.get(reconcileKey) ?? 0;
|
||||
const now = Date.now();
|
||||
|
||||
if (now - last >= TRAKT_RECONCILE_COOLDOWN) {
|
||||
lastTraktReconcileRef.current.set(reconcileKey, now);
|
||||
|
||||
const contentData = buildTraktContentData(item);
|
||||
if (contentData) {
|
||||
const progressToSend =
|
||||
localProgress >= 85
|
||||
? Math.min(localProgress, 100)
|
||||
: Math.min(localProgress, 79.9);
|
||||
|
||||
reconcilePromises.push(
|
||||
traktService.pauseWatching(contentData, progressToSend).catch(() => null)
|
||||
);
|
||||
}
|
||||
}
|
||||
// If Trakt says in-progress (2-85%) but local says completed (>=85%),
|
||||
// trust Trakt's playback endpoint — it's authoritative for paused items.
|
||||
const traktIsInProgress = traktProgress >= 2 && traktProgress < 85;
|
||||
const localSaysCompleted = localProgress >= 85;
|
||||
if (traktIsInProgress && localSaysCompleted) {
|
||||
return {
|
||||
...item,
|
||||
lastUpdated: safeItemTs,
|
||||
};
|
||||
}
|
||||
|
||||
if (((isLocalNewer || isLocalRecent) && isDifferent) || isAhead) {
|
||||
return {
|
||||
...item,
|
||||
progress: localProgress,
|
||||
lastUpdated: mergedLastUpdated,
|
||||
lastUpdated: safeItemTs, // keep Trakt timestamp, only update progress
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
lastUpdated: mergedLastUpdated,
|
||||
lastUpdated: safeItemTs, // keep Trakt timestamp for sort stability
|
||||
};
|
||||
})
|
||||
.filter((item) => (item.progress ?? 0) < 85);
|
||||
});
|
||||
|
||||
adjustedItems.sort(compareContinueWatchingItems);
|
||||
setContinueWatchingItems(adjustedItems);
|
||||
const finalItems = adjustedItems
|
||||
.sort((a, b) => getValidTime(b.lastUpdated) - getValidTime(a.lastUpdated))
|
||||
.slice(0, CW_MAX_DISPLAY_ITEMS);
|
||||
|
||||
if (reconcilePromises.length > 0) {
|
||||
Promise.allSettled(reconcilePromises).catch(() => null);
|
||||
logger.log(`[TraktCW] ═══ FINAL LIST: ${finalItems.length} items (capped at ${CW_MAX_DISPLAY_ITEMS}) ═══`);
|
||||
for (let i = 0; i < finalItems.length; i++) {
|
||||
const item = finalItems[i];
|
||||
const isUpNext = (item.progress ?? 0) === 0 && item.type === 'series';
|
||||
const tag = isUpNext ? 'UP-NEXT' : 'RESUME';
|
||||
const epLabel = item.type === 'series' ? ` S${item.season ?? '?'}E${item.episode ?? '?'}` : '';
|
||||
const ts = getValidTime(item.lastUpdated);
|
||||
logger.log(`[TraktCW] #${i + 1} [${tag}] ${item.name || item.id}${epLabel} — ${item.type} progress=${(item.progress ?? 0).toFixed(1)}% last=${ts ? new Date(ts).toISOString() : 'N/A'}`);
|
||||
}
|
||||
logger.log(`[TraktCW] ═══ END FINAL LIST ═══`);
|
||||
|
||||
setContinueWatchingItems(finalItems);
|
||||
|
||||
if (reconcileLocalPromises.length > 0) {
|
||||
Promise.allSettled(reconcileLocalPromises).catch(() => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -209,6 +209,8 @@ export function useContinueWatchingData() {
|
|||
const simklService = SimklService.getInstance();
|
||||
const isSimklAuthed = !isTraktAuthed ? await simklService.isAuthenticated() : false;
|
||||
|
||||
console.log(`[CW-Hook] Auth state: trakt=${isTraktAuthed} simkl=${isSimklAuthed}`);
|
||||
|
||||
const traktMoviesSetPromise = getTraktMoviesSet(isTraktAuthed, traktService);
|
||||
const traktShowsSetPromise = getTraktShowsSet(isTraktAuthed, traktService);
|
||||
const localWatchedShowsMapPromise = getLocalWatchedShowsMap();
|
||||
|
|
@ -239,7 +241,7 @@ export function useContinueWatchingData() {
|
|||
|
||||
await Promise.allSettled([
|
||||
isTraktAuthed
|
||||
? mergeTraktContinueWatching({
|
||||
? (console.log('[CW-Hook] Calling mergeTraktContinueWatching...'), mergeTraktContinueWatching({
|
||||
traktService,
|
||||
getCachedMetadata,
|
||||
localProgressIndex,
|
||||
|
|
@ -248,8 +250,8 @@ export function useContinueWatchingData() {
|
|||
lastTraktSyncRef,
|
||||
lastTraktReconcileRef,
|
||||
setContinueWatchingItems,
|
||||
})
|
||||
: Promise.resolve(),
|
||||
}))
|
||||
: (console.log('[CW-Hook] Trakt NOT authed, skipping merge'), Promise.resolve()),
|
||||
isSimklAuthed && !isTraktAuthed
|
||||
? mergeSimklContinueWatching({
|
||||
simklService,
|
||||
|
|
|
|||
|
|
@ -271,8 +271,6 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId);
|
||||
|
||||
const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id;
|
||||
|
||||
const { segments: skipIntervals, outroSegment } = useSkipSegments({
|
||||
imdbId: resolvedImdbId || (id?.startsWith('tt') ? id : undefined),
|
||||
type,
|
||||
|
|
@ -490,6 +488,10 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
if (videoDuration > 0) {
|
||||
traktAutosync.handlePlaybackStart(0, videoDuration);
|
||||
}
|
||||
}, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]);
|
||||
|
||||
const handleProgress = useCallback((data: any) => {
|
||||
|
|
@ -945,7 +947,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
addonId: currentStreamProvider
|
||||
}, episodeId);
|
||||
}
|
||||
traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true);
|
||||
traktAutosync.handlePlaybackStart(data.currentTime, playerState.duration);
|
||||
}
|
||||
}}
|
||||
onEnd={() => {
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
duration,
|
||||
lastUpdated: Date.now()
|
||||
}, episodeId);
|
||||
traktAutosync.handlePlaybackStart(timeInSeconds, duration);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -650,9 +651,6 @@ const KSPlayerCore: React.FC = () => {
|
|||
if (isSyncingBeforeClose.current) return;
|
||||
isSyncingBeforeClose.current = true;
|
||||
|
||||
// Fire and forget - don't block navigation on async operations
|
||||
// The useWatchProgress and useTraktAutosync hooks handle cleanup on unmount
|
||||
traktAutosync.handleProgressUpdate(currentTime, duration, true);
|
||||
traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close');
|
||||
|
||||
navigation.goBack();
|
||||
|
|
|
|||
|
|
@ -162,7 +162,6 @@ export const useWatchProgress = (
|
|||
};
|
||||
try {
|
||||
await storageService.setWatchProgress(id, type, progress, episodeId);
|
||||
await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current);
|
||||
|
||||
// Requirement 1: Auto Episode Tracking (>= 90% completion)
|
||||
const progressPercent = (currentTimeRef.current / durationRef.current) * 100;
|
||||
|
|
@ -204,20 +203,24 @@ export const useWatchProgress = (
|
|||
|
||||
|
||||
useEffect(() => {
|
||||
// Handle pause transitions (upstream)
|
||||
if (wasPausedRef.current !== paused) {
|
||||
const becamePaused = paused;
|
||||
wasPausedRef.current = paused;
|
||||
if (becamePaused) {
|
||||
void saveWatchProgress();
|
||||
if (durationRef.current > 0) {
|
||||
void traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'user_close');
|
||||
}
|
||||
} else {
|
||||
if (durationRef.current > 0) {
|
||||
void traktAutosyncRef.current.handlePlaybackStart(currentTimeRef.current, durationRef.current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle periodic save when playing (MAL branch)
|
||||
if (id && type && !paused) {
|
||||
if (progressSaveInterval) clearInterval(progressSaveInterval);
|
||||
|
||||
// Use refs inside the interval so we don't need to restart it on every second
|
||||
const interval = setInterval(() => {
|
||||
saveWatchProgress();
|
||||
}, 10000);
|
||||
|
|
@ -238,7 +241,8 @@ export const useWatchProgress = (
|
|||
setTimeout(() => {
|
||||
if (id && type && durationRef.current > 0) {
|
||||
saveWatchProgress();
|
||||
traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount');
|
||||
// Use ref to avoid stale closure capturing an old traktAutosync instance
|
||||
traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount');
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
const [addonResponseOrder, setAddonResponseOrder] = useState<string[]>([]);
|
||||
// Prevent re-initializing season selection repeatedly for the same series
|
||||
const initializedSeasonRef = useRef(false);
|
||||
const resolvedTypeRef = useRef<string>(normalizedType); // stores TMDB-resolved type for loadStreams
|
||||
|
||||
// Memory optimization: Track stream counts and implement cleanup (limits removed)
|
||||
const streamCountRef = useRef(0);
|
||||
|
|
@ -725,6 +726,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
// If normalizedType is not a known type (e.g. "other" from Gemini/AI search),
|
||||
// resolve the correct type via TMDB before fetching addon metadata.
|
||||
let effectiveType = normalizedType;
|
||||
resolvedTypeRef.current = normalizedType; // reset each load
|
||||
if (normalizedType !== 'movie' && normalizedType !== 'series') {
|
||||
try {
|
||||
if (actualId.startsWith('tt')) {
|
||||
|
|
@ -734,6 +736,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
const resolved = await tmdbSvc.findTypeAndIdByIMDB(actualId);
|
||||
if (resolved) {
|
||||
effectiveType = resolved.type;
|
||||
resolvedTypeRef.current = resolved.type;
|
||||
setTmdbId(resolved.tmdbId);
|
||||
if (__DEV__) console.log(`🔍 [useMetadata] Resolved unknown type "${normalizedType}" → "${effectiveType}" via TMDB /find`);
|
||||
}
|
||||
|
|
@ -751,6 +754,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
// Prefer series when both exist (anime/TV tagged as "other" is usually a series)
|
||||
if (hasSeries) effectiveType = 'series';
|
||||
else if (hasMovie) effectiveType = 'movie';
|
||||
resolvedTypeRef.current = effectiveType;
|
||||
if (__DEV__) console.log(`🔍 [useMetadata] Resolved unknown type "${normalizedType}" → "${effectiveType}" via TMDB parallel check`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1571,7 +1575,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
if (__DEV__) logger.log('🔍 [loadStreams] Getting TMDB ID for:', id);
|
||||
let tmdbId;
|
||||
let stremioId = id;
|
||||
let effectiveStreamType: string = type;
|
||||
// Use TMDB-resolved type if available — handles "other", "Movie", etc.
|
||||
// Use metadata.type first (from addon meta response), then TMDB-resolved, then normalized
|
||||
let effectiveStreamType: string = metadata?.type || resolvedTypeRef.current || normalizedType;
|
||||
|
||||
if (id.startsWith('tmdb:')) {
|
||||
tmdbId = id.split(':')[1];
|
||||
|
|
@ -1626,7 +1632,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
const allStremioAddons = await stremioService.getInstalledAddons();
|
||||
const localScrapers = await localScraperService.getInstalledScrapers();
|
||||
|
||||
const requestedStreamType = type;
|
||||
// Use the best available type — not raw type which may be "other"
|
||||
const requestedStreamType = metadata?.type || resolvedTypeRef.current || normalizedType;
|
||||
|
||||
const pickEligibleStreamAddons = (requestType: string) =>
|
||||
allStremioAddons.filter(addon => {
|
||||
|
|
|
|||
|
|
@ -7,27 +7,39 @@ import { SimklContentData } from '../services/simklService';
|
|||
import { storageService } from '../services/storageService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const TRAKT_SCROBBLE_THRESHOLD = 80;
|
||||
|
||||
interface TraktAutosyncOptions {
|
||||
id: string;
|
||||
type: 'movie' | 'series';
|
||||
title: string;
|
||||
year: number | string; // Allow both for compatibility
|
||||
year: number | string;
|
||||
imdbId: string;
|
||||
// For episodes
|
||||
season?: number;
|
||||
episode?: number;
|
||||
showTitle?: string;
|
||||
showYear?: number | string; // Allow both for compatibility
|
||||
showYear?: number | string;
|
||||
showImdbId?: string;
|
||||
episodeId?: string;
|
||||
}
|
||||
|
||||
const recentlyScrobbledSessions = new Map<string, {
|
||||
scrobbledAt: number;
|
||||
progress: number;
|
||||
}>();
|
||||
const SCROBBLE_DEDUP_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
function getContentKey(opts: TraktAutosyncOptions): string {
|
||||
const resolvedId = (opts.imdbId && opts.imdbId.trim()) ? opts.imdbId : (opts.id || '');
|
||||
return opts.type === 'movie'
|
||||
? `movie:${resolvedId}`
|
||||
: `episode:${opts.showImdbId || resolvedId}:${opts.season}:${opts.episode}`;
|
||||
}
|
||||
|
||||
export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||
const {
|
||||
isAuthenticated,
|
||||
startWatching,
|
||||
updateProgress,
|
||||
updateProgressImmediate,
|
||||
stopWatching,
|
||||
stopWatchingImmediate
|
||||
} = useTraktIntegration();
|
||||
|
|
@ -41,108 +53,87 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
|
||||
const { settings: autosyncSettings } = useTraktAutosyncSettings();
|
||||
|
||||
const hasStartedWatching = useRef(false);
|
||||
const hasStopped = useRef(false); // New: Track if we've already stopped for this session
|
||||
const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled)
|
||||
const isUnmounted = useRef(false); // New: Track if component has unmounted
|
||||
const lastSyncTime = useRef(0);
|
||||
// Session state refs
|
||||
const isSessionComplete = useRef(false); // True once scrobbled (>= 80%) — blocks ALL further payloads
|
||||
const isUnmounted = useRef(false);
|
||||
const lastSyncProgress = useRef(0);
|
||||
const sessionKey = useRef<string | null>(null);
|
||||
const unmountCount = useRef(0);
|
||||
const lastStopCall = useRef(0); // New: Track last stop call timestamp
|
||||
const lastStopCall = useRef(0);
|
||||
|
||||
// Generate a unique session key for this content instance
|
||||
// Initialise session on mount / content change
|
||||
useEffect(() => {
|
||||
const resolvedId = (options.imdbId && options.imdbId.trim()) ? options.imdbId : (options.id || '');
|
||||
const contentKey = options.type === 'movie'
|
||||
? `movie:${resolvedId}`
|
||||
: `episode:${options.showImdbId || resolvedId}:${options.season}:${options.episode}`;
|
||||
const contentKey = getContentKey(options);
|
||||
sessionKey.current = `${contentKey}:${Date.now()}`;
|
||||
isUnmounted.current = false;
|
||||
unmountCount.current = 0;
|
||||
|
||||
// Reset all session state for new content
|
||||
hasStartedWatching.current = false;
|
||||
hasStopped.current = false;
|
||||
isSessionComplete.current = false;
|
||||
isUnmounted.current = false; // Reset unmount flag for new mount
|
||||
lastStopCall.current = 0;
|
||||
|
||||
logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
|
||||
// Check if this content was recently scrobbled (prevents duplicate on remount)
|
||||
const prior = recentlyScrobbledSessions.get(contentKey);
|
||||
const now = Date.now();
|
||||
if (prior && (now - prior.scrobbledAt) < SCROBBLE_DEDUP_WINDOW_MS) {
|
||||
isSessionComplete.current = true;
|
||||
lastSyncProgress.current = prior.progress;
|
||||
logger.log(`[TraktAutosync] Remount detected — content already scrobbled (${prior.progress.toFixed(1)}%), blocking all payloads`);
|
||||
} else {
|
||||
isSessionComplete.current = false;
|
||||
lastSyncProgress.current = 0;
|
||||
lastStopCall.current = 0;
|
||||
if (prior) {
|
||||
recentlyScrobbledSessions.delete(contentKey);
|
||||
}
|
||||
logger.log(`[TraktAutosync] New session started for: ${sessionKey.current}`);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unmountCount.current++;
|
||||
isUnmounted.current = true; // Mark as unmounted to prevent post-unmount operations
|
||||
isUnmounted.current = true;
|
||||
logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`);
|
||||
};
|
||||
}, [options.imdbId, options.season, options.episode, options.type]);
|
||||
|
||||
// Build Trakt content data from options
|
||||
// Returns null if required fields are missing or invalid
|
||||
// ── Build content data helpers ──────────────────────────────────────
|
||||
|
||||
const buildContentData = useCallback((): TraktContentData | null => {
|
||||
// Parse and validate year - returns undefined for invalid/missing years
|
||||
const parseYear = (year: number | string | undefined): number | undefined => {
|
||||
if (year === undefined || year === null || year === '') return undefined;
|
||||
if (typeof year === 'number') {
|
||||
// Year must be a reasonable value (between 1800 and current year + 10)
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (year <= 0 || year < 1800 || year > currentYear + 10) {
|
||||
logger.warn(`[TraktAutosync] Invalid year value: ${year}`);
|
||||
return undefined;
|
||||
}
|
||||
if (year < 1800 || year > currentYear + 10) return undefined;
|
||||
return year;
|
||||
}
|
||||
const parsed = parseInt(year.toString(), 10);
|
||||
if (isNaN(parsed) || parsed <= 0) {
|
||||
logger.warn(`[TraktAutosync] Failed to parse year: ${year}`);
|
||||
return undefined;
|
||||
}
|
||||
// Validate parsed year range
|
||||
if (isNaN(parsed) || parsed <= 0) return undefined;
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (parsed < 1800 || parsed > currentYear + 10) {
|
||||
logger.warn(`[TraktAutosync] Year out of valid range: ${parsed}`);
|
||||
return undefined;
|
||||
}
|
||||
if (parsed < 1800 || parsed > currentYear + 10) return undefined;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
// Validate required fields early
|
||||
if (!options.title || options.title.trim() === '') {
|
||||
logger.error('[TraktAutosync] Cannot build content data: missing or empty title');
|
||||
logger.error('[TraktAutosync] Cannot build content data: missing title');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve the best available ID: prefer a proper IMDb ID, fall back to the Stremio content ID.
|
||||
// This allows scrobbling for content with special IDs (e.g. "kitsu:123", "tmdb:456") where
|
||||
// the IMDb ID hasn't been resolved yet — Trakt will match by title + season/episode instead.
|
||||
const imdbIdRaw = options.imdbId && options.imdbId.trim() ? options.imdbId.trim() : '';
|
||||
const stremioIdRaw = options.id && options.id.trim() ? options.id.trim() : '';
|
||||
const resolvedImdbId = imdbIdRaw || stremioIdRaw;
|
||||
|
||||
if (!resolvedImdbId) {
|
||||
logger.error('[TraktAutosync] Cannot build content data: missing or empty imdbId and id');
|
||||
logger.error('[TraktAutosync] Cannot build content data: missing imdbId and id');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!imdbIdRaw && stremioIdRaw) {
|
||||
logger.warn(`[TraktAutosync] imdbId is empty, falling back to stremio id "${stremioIdRaw}" — Trakt will match by title`);
|
||||
}
|
||||
|
||||
const numericYear = parseYear(options.year);
|
||||
const numericShowYear = parseYear(options.showYear);
|
||||
|
||||
// Log warning if year is missing (but don't fail - Trakt can sometimes work with IMDb ID alone)
|
||||
if (numericYear === undefined) {
|
||||
logger.warn('[TraktAutosync] Year is missing or invalid, proceeding without year');
|
||||
}
|
||||
|
||||
if (options.type === 'movie') {
|
||||
return {
|
||||
type: 'movie',
|
||||
imdbId: resolvedImdbId,
|
||||
title: options.title.trim(),
|
||||
year: numericYear // Can be undefined now
|
||||
year: numericYear
|
||||
};
|
||||
} else {
|
||||
// For episodes, also validate season and episode numbers
|
||||
if (options.season === undefined || options.season === null || options.season < 0) {
|
||||
logger.error('[TraktAutosync] Cannot build episode content data: invalid season');
|
||||
return null;
|
||||
|
|
@ -171,468 +162,219 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
}, [options]);
|
||||
|
||||
const buildSimklContentData = useCallback((): SimklContentData => {
|
||||
// Use the same fallback logic: prefer imdbId, fall back to stremio id
|
||||
const resolvedId = (options.imdbId && options.imdbId.trim())
|
||||
? options.imdbId.trim()
|
||||
: (options.id && options.id.trim()) ? options.id.trim() : '';
|
||||
return {
|
||||
type: options.type === 'series' ? 'episode' : 'movie',
|
||||
title: options.title,
|
||||
ids: {
|
||||
imdb: resolvedId
|
||||
},
|
||||
ids: { imdb: resolvedId },
|
||||
season: options.season,
|
||||
episode: options.episode
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
// Start watching (scrobble start)
|
||||
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
|
||||
if (isUnmounted.current) return; // Prevent execution after component unmount
|
||||
// ── /scrobble/start — play, unpause, seek ──────────────────────────
|
||||
|
||||
logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`);
|
||||
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
|
||||
console.log(`[TraktAutosync] START | time=${currentTime} dur=${duration} unmounted=${isUnmounted.current} complete=${isSessionComplete.current} traktAuth=${isAuthenticated} enabled=${autosyncSettings.enabled} simklAuth=${isSimklAuthenticated}`);
|
||||
if (isUnmounted.current) return;
|
||||
|
||||
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
|
||||
const shouldSyncSimkl = isSimklAuthenticated;
|
||||
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: Trakt (auth=${isAuthenticated}, enabled=${autosyncSettings.enabled}), Simkl (auth=${isSimklAuthenticated})`);
|
||||
return;
|
||||
}
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) return;
|
||||
|
||||
// PREVENT SESSION RESTART: Don't start if session is complete (scrobbled)
|
||||
// After scrobble (>= 80%), send NO more payloads
|
||||
if (isSessionComplete.current) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session is complete, preventing any restart`);
|
||||
logger.log(`[TraktAutosync] Session complete — skipping /scrobble/start`);
|
||||
return;
|
||||
}
|
||||
|
||||
// PREVENT SESSION RESTART: Don't start if we've already stopped this session
|
||||
if (hasStopped.current) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session already stopped, preventing restart`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasStartedWatching.current) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: already started=${hasStartedWatching.current}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (duration <= 0) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: invalid duration (${duration})`);
|
||||
return;
|
||||
}
|
||||
if (duration <= 0) return;
|
||||
|
||||
try {
|
||||
// Clamp progress between 0 and 100
|
||||
const rawProgress = (currentTime / duration) * 100;
|
||||
const progressPercent = Math.min(100, Math.max(0, rawProgress));
|
||||
const contentData = buildContentData();
|
||||
|
||||
// Skip if content data is invalid
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping start: invalid content data');
|
||||
// If we're already past 80%, don't send start — it's already scrobbled or will be
|
||||
if (progressPercent >= TRAKT_SCROBBLE_THRESHOLD) {
|
||||
logger.log(`[TraktAutosync] Progress ${progressPercent.toFixed(1)}% >= ${TRAKT_SCROBBLE_THRESHOLD}%, skipping start`);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) return;
|
||||
|
||||
if (shouldSyncTrakt) {
|
||||
const success = await startWatching(contentData, progressPercent);
|
||||
if (success) {
|
||||
hasStartedWatching.current = true;
|
||||
hasStopped.current = false; // Reset stop flag when starting
|
||||
logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
|
||||
lastSyncProgress.current = progressPercent;
|
||||
logger.log(`[TraktAutosync] /scrobble/start sent: ${contentData.title} at ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
} else {
|
||||
// If Trakt is disabled but Simkl is enabled, we still mark stated/stopped flags for local logic
|
||||
hasStartedWatching.current = true;
|
||||
hasStopped.current = false;
|
||||
}
|
||||
|
||||
// Simkl Start
|
||||
if (shouldSyncSimkl) {
|
||||
const simklData = buildSimklContentData();
|
||||
await startSimkl(simklData, progressPercent);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[TraktAutosync] Error starting watch:', error);
|
||||
logger.error('[TraktAutosync] Error in handlePlaybackStart:', error);
|
||||
}
|
||||
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, startWatching, startSimkl, buildContentData, buildSimklContentData]);
|
||||
|
||||
// Sync progress during playback
|
||||
const handleProgressUpdate = useCallback(async (
|
||||
currentTime: number,
|
||||
duration: number,
|
||||
force: boolean = false
|
||||
) => {
|
||||
if (isUnmounted.current) return; // Prevent execution after component unmount
|
||||
// ── /scrobble/stop — pause, close, unmount, video end ──────────────
|
||||
|
||||
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
|
||||
const shouldSyncSimkl = isSimklAuthenticated;
|
||||
|
||||
if ((!shouldSyncTrakt && !shouldSyncSimkl) || duration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if session is already complete
|
||||
if (isSessionComplete.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawProgress = (currentTime / duration) * 100;
|
||||
const progressPercent = Math.min(100, Math.max(0, rawProgress));
|
||||
const now = Date.now();
|
||||
|
||||
// IMMEDIATE SYNC: Use immediate method for user-triggered actions (force=true)
|
||||
// Use regular queued method for background periodic syncs
|
||||
let traktSuccess: boolean = false;
|
||||
|
||||
if (shouldSyncTrakt) {
|
||||
if (force) {
|
||||
// IMMEDIATE: User action (pause/unpause) - bypass queue
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
traktSuccess = await updateProgressImmediate(contentData, progressPercent);
|
||||
|
||||
if (traktSuccess) {
|
||||
lastSyncTime.current = now;
|
||||
lastSyncProgress.current = progressPercent;
|
||||
|
||||
// Update local storage sync status
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
|
||||
logger.log(`[TraktAutosync] Trakt IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
} else {
|
||||
// BACKGROUND: Periodic sync - use queued method
|
||||
const progressDiff = Math.abs(progressPercent - lastSyncProgress.current);
|
||||
|
||||
// Only skip if not forced and progress difference is minimal (< 0.5%)
|
||||
if (progressDiff < 0.5) {
|
||||
logger.log(`[TraktAutosync] Trakt: Skipping periodic progress update, progress diff too small (${progressDiff.toFixed(2)}%)`);
|
||||
// If only Trakt is active and we skip, we should return here.
|
||||
// If Simkl is also active, we continue to let Simkl update.
|
||||
if (!shouldSyncSimkl) return;
|
||||
}
|
||||
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
traktSuccess = await updateProgress(contentData, progressPercent, force);
|
||||
|
||||
if (traktSuccess) {
|
||||
lastSyncTime.current = now;
|
||||
lastSyncProgress.current = progressPercent;
|
||||
|
||||
// Update local storage sync status
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
|
||||
// Progress sync logging removed
|
||||
logger.log(`[TraktAutosync] Trakt: Progress updated to ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simkl Update (No immediate/queued differentiation for now in Simkl hook, just call update)
|
||||
if (shouldSyncSimkl) {
|
||||
// Debounce simkl updates slightly if needed, but hook handles calls.
|
||||
// We do basic difference check here
|
||||
const simklData = buildSimklContentData();
|
||||
await updateSimkl(simklData, progressPercent);
|
||||
|
||||
// Update local storage for Simkl
|
||||
await storageService.updateSimklSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId
|
||||
);
|
||||
logger.log(`[TraktAutosync] Simkl: Progress updated to ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[TraktAutosync] Error syncing progress:', error);
|
||||
}
|
||||
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, updateProgress, updateSimkl, updateProgressImmediate, buildContentData, buildSimklContentData, options]);
|
||||
|
||||
// Handle playback end/pause
|
||||
const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => {
|
||||
if (isUnmounted.current) return; // Prevent execution after component unmount
|
||||
console.log(`[TraktAutosync] STOP | time=${currentTime} dur=${duration} reason=${reason} unmounted=${isUnmounted.current} complete=${isSessionComplete.current} traktAuth=${isAuthenticated} enabled=${autosyncSettings.enabled}`);
|
||||
if (isUnmounted.current && reason !== 'unmount') return;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Removed excessive logging for handlePlaybackEnd calls
|
||||
|
||||
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
|
||||
const shouldSyncSimkl = isSimklAuthenticated;
|
||||
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: Neither Trakt nor Simkl are active.`);
|
||||
return;
|
||||
}
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) return;
|
||||
|
||||
// ENHANCED DEDUPLICATION: Check if session is already complete
|
||||
// After scrobble (>= 80%), send NO more payloads — prevents duplicate entries
|
||||
if (isSessionComplete.current) {
|
||||
logger.log(`[TraktAutosync] Session already complete, skipping end call (reason: ${reason})`);
|
||||
logger.log(`[TraktAutosync] Session complete — skipping /scrobble/stop (reason: ${reason})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ENHANCED DEDUPLICATION: Check if we've already stopped this session
|
||||
// However, allow updates if the new progress is significantly higher (>5% improvement)
|
||||
let isSignificantUpdate = false;
|
||||
if (hasStopped.current) {
|
||||
const currentProgressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const progressImprovement = currentProgressPercent - lastSyncProgress.current;
|
||||
|
||||
if (progressImprovement > 5) {
|
||||
logger.log(`[TraktAutosync] Session already stopped, but progress improved significantly by ${progressImprovement.toFixed(1)}% (${lastSyncProgress.current.toFixed(1)}% → ${currentProgressPercent.toFixed(1)}%), allowing update`);
|
||||
// Reset stopped flag to allow this significant update
|
||||
hasStopped.current = false;
|
||||
isSignificantUpdate = true;
|
||||
} else {
|
||||
// Already stopped this session, skipping duplicate call
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: session already stopped and no significant progress improvement.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// IMMEDIATE SYNC: Use immediate method for user-initiated actions (user_close)
|
||||
let useImmediate = reason === 'user_close';
|
||||
|
||||
// IMMEDIATE SYNC: Remove debouncing for instant sync when closing
|
||||
// Only prevent truly duplicate calls (within 500ms for regular, 100ms for immediate)
|
||||
const debounceThreshold = useImmediate ? 100 : 500;
|
||||
if (!isSignificantUpdate && now - lastStopCall.current < debounceThreshold) {
|
||||
logger.log(`[TraktAutosync] Ignoring duplicate stop call within ${debounceThreshold}ms (reason: ${reason})`);
|
||||
// Debounce: prevent duplicate stop calls within 500ms
|
||||
if (now - lastStopCall.current < 500) {
|
||||
logger.log(`[TraktAutosync] Ignoring duplicate stop call within 500ms (reason: ${reason})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip rapid unmount calls (likely from React strict mode or component remounts)
|
||||
if (reason === 'unmount' && unmountCount.current > 1) {
|
||||
logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`);
|
||||
return;
|
||||
}
|
||||
// Skip duplicate unmount calls (React strict mode)
|
||||
if (reason === 'unmount' && unmountCount.current > 1) return;
|
||||
|
||||
try {
|
||||
let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
// Clamp progress between 0 and 100
|
||||
progressPercent = Math.min(100, Math.max(0, progressPercent));
|
||||
// Initial progress calculation logging removed
|
||||
|
||||
// For unmount calls, always use the highest available progress
|
||||
// Check current progress, last synced progress, and local storage progress
|
||||
// For unmount, use highest known progress
|
||||
if (reason === 'unmount') {
|
||||
let maxProgress = progressPercent;
|
||||
|
||||
// Check last synced progress
|
||||
if (lastSyncProgress.current > maxProgress) {
|
||||
maxProgress = lastSyncProgress.current;
|
||||
if (lastSyncProgress.current > progressPercent) {
|
||||
progressPercent = lastSyncProgress.current;
|
||||
}
|
||||
|
||||
// Also check local storage for the highest recorded progress
|
||||
try {
|
||||
const savedProgress = await storageService.getWatchProgress(
|
||||
options.id,
|
||||
options.type,
|
||||
options.episodeId
|
||||
);
|
||||
|
||||
const savedProgress = await storageService.getWatchProgress(options.id, options.type, options.episodeId);
|
||||
if (savedProgress && savedProgress.duration > 0) {
|
||||
const savedProgressPercent = Math.min(100, Math.max(0, (savedProgress.currentTime / savedProgress.duration) * 100));
|
||||
if (savedProgressPercent > maxProgress) {
|
||||
maxProgress = savedProgressPercent;
|
||||
}
|
||||
const savedPercent = Math.min(100, Math.max(0, (savedProgress.currentTime / savedProgress.duration) * 100));
|
||||
if (savedPercent > progressPercent) progressPercent = savedPercent;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[TraktAutosync] Error checking saved progress:', error);
|
||||
}
|
||||
|
||||
if (maxProgress !== progressPercent) {
|
||||
// Highest progress logging removed
|
||||
progressPercent = maxProgress;
|
||||
} else {
|
||||
// Current progress logging removed
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// If we have valid progress but no started session, force start one first
|
||||
if (!hasStartedWatching.current && progressPercent > 1) {
|
||||
const contentData = buildContentData();
|
||||
if (contentData) {
|
||||
let started = false;
|
||||
// Try starting Trakt if enabled
|
||||
if (shouldSyncTrakt) {
|
||||
const s = await startWatching(contentData, progressPercent);
|
||||
if (s) started = true;
|
||||
}
|
||||
// Try starting Simkl if enabled (always 'true' effectively if authenticated)
|
||||
if (shouldSyncSimkl) {
|
||||
const simklData = buildSimklContentData();
|
||||
await startSimkl(simklData, progressPercent);
|
||||
started = true;
|
||||
}
|
||||
|
||||
if (started) {
|
||||
hasStartedWatching.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only stop if we have meaningful progress (>= 0.5%) or it's a natural video end
|
||||
// Lower threshold for unmount calls to catch more edge cases
|
||||
if (reason === 'unmount' && progressPercent < 0.5) {
|
||||
// Early unmount stop logging removed
|
||||
logger.log(`[TraktAutosync] Skipping unmount stop call due to minimal progress (${progressPercent.toFixed(1)}%)`);
|
||||
// Trakt ignores progress < 1% (returns 422)
|
||||
if (progressPercent < 1) {
|
||||
logger.log(`[TraktAutosync] Progress ${progressPercent.toFixed(1)}% < 1%, skipping stop`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: No longer boosting progress since Trakt API handles 80% threshold correctly
|
||||
|
||||
// Mark stop attempt and update timestamp
|
||||
lastStopCall.current = now;
|
||||
hasStopped.current = true;
|
||||
lastSyncProgress.current = progressPercent;
|
||||
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) return;
|
||||
|
||||
// Skip if content data is invalid
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping stop: invalid content data');
|
||||
hasStopped.current = false; // Allow retry with valid data
|
||||
return;
|
||||
}
|
||||
|
||||
let overallSuccess = false;
|
||||
|
||||
// IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends
|
||||
let traktStopSuccess = false;
|
||||
// Send /scrobble/stop to Trakt
|
||||
// Trakt API: >= 80% → scrobble (marks watched), 1-79% → pause (saves progress)
|
||||
let traktSuccess = false;
|
||||
if (shouldSyncTrakt) {
|
||||
traktStopSuccess = useImmediate
|
||||
const useImmediate = reason === 'user_close';
|
||||
traktSuccess = useImmediate
|
||||
? await stopWatchingImmediate(contentData, progressPercent)
|
||||
: await stopWatching(contentData, progressPercent);
|
||||
if (traktStopSuccess) {
|
||||
logger.log(`[TraktAutosync] Trakt: ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
overallSuccess = true;
|
||||
} else {
|
||||
logger.warn(`[TraktAutosync] Trakt: Failed to stop watching.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (traktStopSuccess) {
|
||||
// Update local storage sync status for Trakt
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
} else if (shouldSyncTrakt) {
|
||||
// If Trakt stop failed, reset the stop flag so we can try again later
|
||||
hasStopped.current = false;
|
||||
logger.warn(`[TraktAutosync] Trakt: Failed to stop watching, reset stop flag for retry`);
|
||||
if (traktSuccess) {
|
||||
logger.log(`[TraktAutosync] /scrobble/stop sent: ${contentData.title} at ${progressPercent.toFixed(1)}% (${reason})`);
|
||||
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id, options.type, true, progressPercent, options.episodeId, currentTime
|
||||
);
|
||||
|
||||
// If >= 80%, Trakt has scrobbled it — mark session complete, no more payloads
|
||||
if (progressPercent >= TRAKT_SCROBBLE_THRESHOLD) {
|
||||
isSessionComplete.current = true;
|
||||
recentlyScrobbledSessions.set(getContentKey(options), {
|
||||
scrobbledAt: now,
|
||||
progress: progressPercent
|
||||
});
|
||||
logger.log(`[TraktAutosync] Scrobbled at ${progressPercent.toFixed(1)}% — session complete, no more payloads`);
|
||||
|
||||
// Update local storage to reflect watched status
|
||||
try {
|
||||
if (duration > 0) {
|
||||
await storageService.setWatchProgress(
|
||||
options.id, options.type,
|
||||
{
|
||||
currentTime: duration,
|
||||
duration,
|
||||
lastUpdated: Date.now(),
|
||||
traktSynced: true,
|
||||
traktProgress: Math.max(progressPercent, 100),
|
||||
simklSynced: shouldSyncSimkl ? true : undefined,
|
||||
simklProgress: shouldSyncSimkl ? Math.max(progressPercent, 100) : undefined,
|
||||
} as any,
|
||||
options.episodeId,
|
||||
{ forceNotify: true }
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[TraktAutosync] Failed to send /scrobble/stop`);
|
||||
}
|
||||
}
|
||||
|
||||
// Simkl Stop
|
||||
if (shouldSyncSimkl) {
|
||||
const simklData = buildSimklContentData();
|
||||
await stopSimkl(simklData, progressPercent);
|
||||
|
||||
// Update local storage sync status for Simkl
|
||||
await storageService.updateSimklSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId
|
||||
options.id, options.type, true, progressPercent, options.episodeId
|
||||
);
|
||||
logger.log(`[TraktAutosync] Simkl: Successfully stopped watching: ${simklData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
overallSuccess = true; // Mark overall success if at least one worked (Simkl doesn't have immediate/queued logic yet)
|
||||
logger.log(`[TraktAutosync] Simkl stop sent: ${simklData.title} at ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
if (overallSuccess) {
|
||||
// Mark session as complete if >= user completion threshold
|
||||
if (progressPercent >= autosyncSettings.completionThreshold) {
|
||||
isSessionComplete.current = true;
|
||||
logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`);
|
||||
|
||||
// Ensure local watch progress reflects completion so UI shows as watched
|
||||
try {
|
||||
if (duration > 0) {
|
||||
await storageService.setWatchProgress(
|
||||
options.id,
|
||||
options.type,
|
||||
{
|
||||
currentTime: duration,
|
||||
duration,
|
||||
lastUpdated: Date.now(),
|
||||
traktSynced: shouldSyncTrakt ? true : undefined,
|
||||
traktProgress: shouldSyncTrakt ? Math.max(progressPercent, 100) : undefined,
|
||||
simklSynced: shouldSyncSimkl ? true : undefined,
|
||||
simklProgress: shouldSyncSimkl ? Math.max(progressPercent, 100) : undefined,
|
||||
} as any,
|
||||
options.episodeId,
|
||||
{ forceNotify: true }
|
||||
);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// General success log if at least one service succeeded
|
||||
if (!shouldSyncTrakt || traktStopSuccess) { // Only log this if Trakt succeeded or wasn't active
|
||||
logger.log(`[TraktAutosync] Overall: Successfully processed stop for: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
}
|
||||
} else {
|
||||
// If neither service succeeded, reset the stop flag
|
||||
hasStopped.current = false;
|
||||
logger.warn(`[TraktAutosync] Overall: Failed to stop watching, reset stop flag for retry`);
|
||||
}
|
||||
|
||||
// Reset state only for natural end or very high progress unmounts
|
||||
if (reason === 'ended' || progressPercent >= 80) {
|
||||
hasStartedWatching.current = false;
|
||||
lastSyncTime.current = 0;
|
||||
lastSyncProgress.current = 0;
|
||||
logger.log(`[TraktAutosync] Reset session state for ${reason} at ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[TraktAutosync] Error ending watch:', error);
|
||||
// Reset stop flag on error so we can try again
|
||||
hasStopped.current = false;
|
||||
logger.error('[TraktAutosync] Error in handlePlaybackEnd:', error);
|
||||
}
|
||||
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, stopWatching, stopSimkl, stopWatchingImmediate, startWatching, buildContentData, buildSimklContentData, options]);
|
||||
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, stopWatching, stopSimkl, stopWatchingImmediate, buildContentData, buildSimklContentData, options]);
|
||||
|
||||
// handleProgressUpdate — kept for Simkl compatibility only.
|
||||
// Trakt does NOT need periodic progress updates; only start/stop events.
|
||||
const handleProgressUpdate = useCallback(async (
|
||||
currentTime: number,
|
||||
duration: number,
|
||||
_force: boolean = false
|
||||
) => {
|
||||
if (isUnmounted.current || duration <= 0) return;
|
||||
if (isSessionComplete.current) return;
|
||||
|
||||
// Only update Simkl if authenticated — Trakt needs no periodic updates
|
||||
if (isSimklAuthenticated) {
|
||||
try {
|
||||
const rawProgress = (currentTime / duration) * 100;
|
||||
const progressPercent = Math.min(100, Math.max(0, rawProgress));
|
||||
const simklData = buildSimklContentData();
|
||||
await updateSimkl(simklData, progressPercent);
|
||||
} catch (error) {
|
||||
logger.error('[TraktAutosync] Error updating Simkl progress:', error);
|
||||
}
|
||||
}
|
||||
}, [isSimklAuthenticated, updateSimkl, buildSimklContentData]);
|
||||
|
||||
// Reset state (useful when switching content)
|
||||
const resetState = useCallback(() => {
|
||||
hasStartedWatching.current = false;
|
||||
hasStopped.current = false;
|
||||
isSessionComplete.current = false;
|
||||
isUnmounted.current = false;
|
||||
lastSyncTime.current = 0;
|
||||
lastSyncProgress.current = 0;
|
||||
unmountCount.current = 0;
|
||||
sessionKey.current = null;
|
||||
lastStopCall.current = 0;
|
||||
recentlyScrobbledSessions.delete(getContentKey(options));
|
||||
logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`);
|
||||
}, [options.title]);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export interface TraktAutosyncSettings {
|
|||
const DEFAULT_SETTINGS: TraktAutosyncSettings = {
|
||||
enabled: true,
|
||||
syncFrequency: 60000, // 60 seconds
|
||||
completionThreshold: 95, // 95%
|
||||
completionThreshold: 80, // 80% — Trakt API hardcoded threshold
|
||||
};
|
||||
|
||||
export function useTraktAutosyncSettings() {
|
||||
|
|
|
|||
|
|
@ -325,12 +325,21 @@ async function searchAddonCatalog(
|
|||
// meta addon by ID prefix matching. Setting it here causes 404s when two addons
|
||||
// are installed and one returns IDs the other can't serve metadata for.
|
||||
|
||||
const normalizedCatalogType = type ? type.toLowerCase() : type;
|
||||
if (normalizedCatalogType && content.type !== normalizedCatalogType) {
|
||||
content.type = normalizedCatalogType;
|
||||
} else if (content.type) {
|
||||
// Always lowercase the item's own type first
|
||||
if (content.type) {
|
||||
content.type = content.type.toLowerCase();
|
||||
}
|
||||
|
||||
// Only stamp the catalog type if the item doesn't already have a standard type.
|
||||
// Prevents catalog type "other" from overwriting correct types like "movie"/"series"
|
||||
// that the addon already set on individual items.
|
||||
const normalizedCatalogType = type ? type.toLowerCase() : type;
|
||||
const STANDARD_TYPES = new Set(['movie', 'series', 'anime.movie', 'anime.series', 'anime', 'tv', 'channel']);
|
||||
if (normalizedCatalogType && !STANDARD_TYPES.has(content.type) && STANDARD_TYPES.has(normalizedCatalogType)) {
|
||||
content.type = normalizedCatalogType;
|
||||
} else if (normalizedCatalogType && !content.type) {
|
||||
content.type = normalizedCatalogType;
|
||||
}
|
||||
return content;
|
||||
});
|
||||
|
||||
|
|
@ -384,9 +393,17 @@ function dedupeAndStampResults(results: StreamingContent[], catalogType: string)
|
|||
}
|
||||
}
|
||||
|
||||
return Array.from(bestById.values()).map(item =>
|
||||
catalogType && item.type !== catalogType ? { ...item, type: catalogType } : item
|
||||
);
|
||||
const normalizedCatalogType = catalogType ? catalogType.toLowerCase() : catalogType;
|
||||
const STANDARD_TYPES = new Set(['movie', 'series', 'anime.movie', 'anime.series', 'anime', 'tv', 'channel']);
|
||||
|
||||
return Array.from(bestById.values()).map(item => {
|
||||
// Only stamp catalog type if item doesn't already have a standard type.
|
||||
// Prevents "other" from overwriting correct types like "movie"/"series".
|
||||
if (normalizedCatalogType && !STANDARD_TYPES.has(item.type) && STANDARD_TYPES.has(normalizedCatalogType)) {
|
||||
return { ...item, type: normalizedCatalogType };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function buildSectionName(
|
||||
|
|
|
|||
|
|
@ -1038,27 +1038,55 @@ class SupabaseSyncService {
|
|||
videoId: string;
|
||||
progressKey: string;
|
||||
} | null {
|
||||
const parts = key.split(':');
|
||||
if (parts.length < 2) return null;
|
||||
// Key format from buildWpKeyString: "{type}:{contentId}" or "{type}:{contentId}:{episodeId}"
|
||||
// contentId may contain colons (e.g., "tmdb:1399", "kitsu:12345")
|
||||
// episodeId ends with ":{season}:{episode}" digits
|
||||
const typeIdx = key.indexOf(':');
|
||||
if (typeIdx < 0) return null;
|
||||
|
||||
const contentType: 'movie' | 'series' = parts[0] === 'movie' ? 'movie' : 'series';
|
||||
const contentId = parts[1];
|
||||
const episodeId = parts.length > 2 ? parts.slice(2).join(':') : '';
|
||||
const typePart = key.substring(0, typeIdx);
|
||||
if (typePart !== 'movie' && typePart !== 'series') return null;
|
||||
const contentType: 'movie' | 'series' = typePart;
|
||||
|
||||
const rest = key.substring(typeIdx + 1);
|
||||
if (!rest) return null;
|
||||
|
||||
// Extract content ID: detect known prefixed patterns (tmdb:NNN, kitsu:NNN),
|
||||
// otherwise take the first colon-free segment (e.g., tt12345).
|
||||
const cidPrefixMatch = rest.match(/^((?:tmdb|kitsu):\d+)/);
|
||||
const contentId = cidPrefixMatch ? cidPrefixMatch[1] : rest.split(':')[0];
|
||||
if (!contentId) return null;
|
||||
|
||||
const afterContentId = rest.substring(contentId.length);
|
||||
|
||||
if (!afterContentId || afterContentId === ':') {
|
||||
// No episode info (movie or series-level)
|
||||
return {
|
||||
contentType,
|
||||
contentId,
|
||||
season: null,
|
||||
episode: null,
|
||||
videoId: contentId,
|
||||
progressKey: contentId,
|
||||
};
|
||||
}
|
||||
|
||||
// Strip leading ":" to get episodeId
|
||||
const episodeId = afterContentId.substring(1);
|
||||
|
||||
// Extract season:episode from the end of episodeId
|
||||
let season: number | null = null;
|
||||
let episode: number | null = null;
|
||||
|
||||
if (episodeId) {
|
||||
const match = episodeId.match(/:(\d+):(\d+)$/);
|
||||
if (match) {
|
||||
season = Number(match[1]);
|
||||
episode = Number(match[2]);
|
||||
}
|
||||
const seMatch = episodeId.match(/:(\d+):(\d+)$/);
|
||||
if (seMatch) {
|
||||
season = Number(seMatch[1]);
|
||||
episode = Number(seMatch[2]);
|
||||
}
|
||||
|
||||
const videoId = episodeId || contentId;
|
||||
const progressKey = contentType === 'movie'
|
||||
? contentId
|
||||
: (season != null && episode != null ? `${contentId}_s${season}e${episode}` : `${contentId}_${videoId}`);
|
||||
const progressKey = season != null && episode != null
|
||||
? `${contentId}_s${season}e${episode}`
|
||||
: `${contentId}_${episodeId}`;
|
||||
|
||||
return {
|
||||
contentType,
|
||||
|
|
@ -1365,7 +1393,7 @@ class SupabaseSyncService {
|
|||
const season = row.season == null ? null : Number(row.season);
|
||||
const episode = row.episode == null ? null : Number(row.episode);
|
||||
const episodeId = type === 'series' && season != null && episode != null
|
||||
? `${row.content_id}:${season}:${episode}`
|
||||
? (row.video_id && row.video_id !== row.content_id ? row.video_id : `${row.content_id}:${season}:${episode}`)
|
||||
: undefined;
|
||||
remoteSet.add(this.buildLocalWatchProgressKey(type, row.content_id, episodeId));
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ if (!TRAKT_CLIENT_ID || !TRAKT_CLIENT_SECRET) {
|
|||
logger.warn('[TraktService] Missing Trakt env vars. Trakt integration will be disabled.');
|
||||
}
|
||||
|
||||
// Trakt API scrobble threshold — hardcoded per API spec.
|
||||
// /scrobble/stop with progress >= 80% → scrobble (marks watched).
|
||||
// /scrobble/stop with progress 1-79% → pause (saves playback progress).
|
||||
const TRAKT_SCROBBLE_THRESHOLD = 80;
|
||||
|
||||
// Types
|
||||
export interface TraktUser {
|
||||
username: string;
|
||||
|
|
@ -690,9 +695,13 @@ export class TraktService {
|
|||
const now = Date.now();
|
||||
let cleanupCount = 0;
|
||||
|
||||
// Remove stop calls older than the debounce window
|
||||
// Retain stop records for 5 minutes so the restart-prevention guard in
|
||||
// scrobbleStart() has time to work. The old value was STOP_DEBOUNCE_MS (1s),
|
||||
// which meant every 15-minute cleanup tick wiped all stop records immediately,
|
||||
// completely defeating the 30s restart window.
|
||||
const STOP_RETENTION_MS = 5 * 60 * 1000;
|
||||
for (const [key, timestamp] of this.lastStopCalls.entries()) {
|
||||
if (now - timestamp > this.STOP_DEBOUNCE_MS) {
|
||||
if (now - timestamp > STOP_RETENTION_MS) {
|
||||
this.lastStopCalls.delete(key);
|
||||
cleanupCount++;
|
||||
}
|
||||
|
|
@ -1867,21 +1876,24 @@ export class TraktService {
|
|||
*/
|
||||
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);
|
||||
console.log('[TraktService] /scrobble/start INVALID content:', validation.errors);
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await this.buildScrobblePayload(contentData, progress);
|
||||
if (!payload) {
|
||||
console.log('[TraktService] /scrobble/start payload is null');
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.apiRequest<TraktScrobbleResponse>('/scrobble/start', 'POST', payload);
|
||||
console.log('[TraktService] /scrobble/start PAYLOAD:', JSON.stringify(payload));
|
||||
const response = await this.apiRequest<TraktScrobbleResponse>('/scrobble/start', 'POST', payload);
|
||||
console.log('[TraktService] /scrobble/start RESPONSE:', JSON.stringify(response));
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to start watching:', error);
|
||||
console.log('[TraktService] /scrobble/start ERROR:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1923,21 +1935,24 @@ export class TraktService {
|
|||
*/
|
||||
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);
|
||||
console.log('[TraktService] /scrobble/stop INVALID content:', validation.errors);
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await this.buildScrobblePayload(contentData, progress);
|
||||
if (!payload) {
|
||||
console.log('[TraktService] /scrobble/stop payload is null');
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.apiRequest<TraktScrobbleResponse>('/scrobble/stop', 'POST', payload);
|
||||
console.log('[TraktService] /scrobble/stop PAYLOAD:', JSON.stringify(payload));
|
||||
const response = await this.apiRequest<TraktScrobbleResponse>('/scrobble/stop', 'POST', payload);
|
||||
console.log('[TraktService] /scrobble/stop RESPONSE:', JSON.stringify(response));
|
||||
return response;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to stop watching:', error);
|
||||
console.log('[TraktService] /scrobble/stop ERROR:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -2235,31 +2250,28 @@ export class TraktService {
|
|||
public async scrobbleStart(contentData: TraktContentData, progress: number): Promise<boolean> {
|
||||
try {
|
||||
if (!await this.isAuthenticated()) {
|
||||
console.log('[TraktService] scrobbleStart: not authenticated');
|
||||
return false;
|
||||
}
|
||||
|
||||
const watchingKey = this.getWatchingKey(contentData);
|
||||
console.log(`[TraktService] scrobbleStart: key=${watchingKey} recentlyScrobbled=${this.isRecentlyScrobbled(contentData)} scrobbled=${this.scrobbledItems.has(watchingKey)} currentlyWatching=${this.currentlyWatching.has(watchingKey)}`);
|
||||
|
||||
// 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}`);
|
||||
console.log(`[TraktService] scrobbleStart BLOCKED: recently scrobbled`);
|
||||
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;
|
||||
if (this.scrobbledItems.has(watchingKey)) {
|
||||
const scrobbledTime = this.scrobbledTimestamps.get(watchingKey);
|
||||
if (scrobbledTime && (Date.now() - scrobbledTime) < 30000) {
|
||||
console.log(`[TraktService] scrobbleStart BLOCKED: scrobbled ${((Date.now() - scrobbledTime) / 1000).toFixed(1)}s ago`);
|
||||
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
|
||||
this.currentlyWatching.delete(watchingKey);
|
||||
}
|
||||
|
||||
const result = await this.queueRequest(async () => {
|
||||
|
|
@ -2268,13 +2280,14 @@ export class TraktService {
|
|||
|
||||
if (result) {
|
||||
this.currentlyWatching.add(watchingKey);
|
||||
logger.log(`[TraktService] Started watching ${contentData.type}: ${contentData.title}`);
|
||||
console.log(`[TraktService] scrobbleStart SUCCESS: ${contentData.title}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`[TraktService] scrobbleStart FAILED: result was null`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error('[TraktService] Failed to start scrobbling:', error);
|
||||
console.log('[TraktService] scrobbleStart ERROR:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -2323,7 +2336,11 @@ export class TraktService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Stop watching content (use when playback ends or stops)
|
||||
* Stop watching content (use when playback ends, pauses, or stops)
|
||||
* Always sends /scrobble/stop — Trakt API automatically handles:
|
||||
* - progress >= 80% → scrobble (marks as watched)
|
||||
* - progress 1-79% → pause (saves playback progress)
|
||||
* - progress < 1% → 422 ignored
|
||||
*/
|
||||
public async scrobbleStop(contentData: TraktContentData, progress: number): Promise<boolean> {
|
||||
try {
|
||||
|
|
@ -2334,52 +2351,48 @@ export class TraktService {
|
|||
const watchingKey = this.getWatchingKey(contentData);
|
||||
const now = Date.now();
|
||||
|
||||
// IMMEDIATE SYNC: Reduce debouncing for instant sync, only prevent truly duplicate calls (< 1 second)
|
||||
if (this.isRecentlyScrobbled(contentData)) {
|
||||
logger.log(`[TraktService] Already scrobbled, skipping stop: ${contentData.title}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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
|
||||
return true;
|
||||
}
|
||||
|
||||
// Record this stop attempt
|
||||
this.lastStopCalls.set(watchingKey, now);
|
||||
|
||||
// Use pause if below user threshold, stop only when ready to scrobble
|
||||
const useStop = progress >= this.completionThreshold;
|
||||
// Always use /scrobble/stop — Trakt decides pause vs scrobble based on progress
|
||||
const result = await this.queueRequest(async () => {
|
||||
return useStop
|
||||
? await this.stopWatching(contentData, progress)
|
||||
: await this.pauseWatching(contentData, progress);
|
||||
return await this.stopWatching(contentData, progress);
|
||||
});
|
||||
|
||||
if (result) {
|
||||
this.currentlyWatching.delete(watchingKey);
|
||||
|
||||
// Mark as scrobbled if >= user threshold to prevent future duplicates and restarts
|
||||
if (progress >= this.completionThreshold) {
|
||||
// Mark as scrobbled if >= 80% to prevent future duplicates
|
||||
if (progress >= TRAKT_SCROBBLE_THRESHOLD) {
|
||||
this.scrobbledItems.add(watchingKey);
|
||||
this.scrobbledTimestamps.set(watchingKey, Date.now());
|
||||
logger.log(`[TraktService] Marked as scrobbled to prevent restarts: ${watchingKey}`);
|
||||
logger.log(`[TraktService] Scrobbled (>= 80%): ${watchingKey}`);
|
||||
}
|
||||
|
||||
// Action reflects actual endpoint used based on user threshold
|
||||
const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused';
|
||||
logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
|
||||
|
||||
const action = progress >= TRAKT_SCROBBLE_THRESHOLD ? 'scrobbled' : 'paused';
|
||||
logger.log(`[TraktService] /scrobble/stop sent: ${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;
|
||||
}
|
||||
|
|
@ -2425,6 +2438,7 @@ export class TraktService {
|
|||
|
||||
/**
|
||||
* Immediate scrobble stop - bypasses queue for instant user feedback
|
||||
* Always sends /scrobble/stop — Trakt handles pause vs scrobble based on progress.
|
||||
*/
|
||||
public async scrobbleStopImmediate(contentData: TraktContentData, progress: number): Promise<boolean> {
|
||||
try {
|
||||
|
|
@ -2434,7 +2448,12 @@ export class TraktService {
|
|||
|
||||
const watchingKey = this.getWatchingKey(contentData);
|
||||
|
||||
// MINIMAL DEDUPLICATION: Only prevent calls within 200ms for immediate actions
|
||||
if (this.isRecentlyScrobbled(contentData)) {
|
||||
logger.log(`[TraktService] Already scrobbled, skipping immediate stop: ${contentData.title}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prevent calls within 200ms for immediate actions
|
||||
const lastStopTime = this.lastStopCalls.get(watchingKey);
|
||||
if (lastStopTime && (Date.now() - lastStopTime) < 200) {
|
||||
return true;
|
||||
|
|
@ -2442,24 +2461,19 @@ export class TraktService {
|
|||
|
||||
this.lastStopCalls.set(watchingKey, Date.now());
|
||||
|
||||
// BYPASS QUEUE: Use pause if below user threshold, stop only when ready to scrobble
|
||||
const useStop = progress >= this.completionThreshold;
|
||||
const result = useStop
|
||||
? await this.stopWatching(contentData, progress)
|
||||
: await this.pauseWatching(contentData, progress);
|
||||
// Always use /scrobble/stop — Trakt decides pause vs scrobble based on progress
|
||||
const result = await this.stopWatching(contentData, progress);
|
||||
|
||||
if (result) {
|
||||
this.currentlyWatching.delete(watchingKey);
|
||||
|
||||
// Mark as scrobbled if >= user threshold to prevent future duplicates and restarts
|
||||
if (progress >= this.completionThreshold) {
|
||||
if (progress >= TRAKT_SCROBBLE_THRESHOLD) {
|
||||
this.scrobbledItems.add(watchingKey);
|
||||
this.scrobbledTimestamps.set(watchingKey, Date.now());
|
||||
}
|
||||
|
||||
// Action reflects actual endpoint used based on user threshold
|
||||
const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused';
|
||||
logger.log(`[TraktService] IMMEDIATE: Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
|
||||
const action = progress >= TRAKT_SCROBBLE_THRESHOLD ? 'scrobbled' : 'paused';
|
||||
logger.log(`[TraktService] IMMEDIATE /scrobble/stop: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -3246,14 +3260,12 @@ export class TraktService {
|
|||
*/
|
||||
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
|
||||
// Only clear the request queue to prevent background processing.
|
||||
// DO NOT clear scrobbledItems / currentlyWatching / lastStopCalls here.
|
||||
// Clearing them causes duplicate scrobble entries when the app backgrounds
|
||||
// during a long pause and then resumes — all dedup guards are gone and
|
||||
// scrobbleStart fires a fresh /scrobble/start for the same content.
|
||||
// These maps are small and already expire via cleanupOldStopCalls().
|
||||
this.requestQueue = [];
|
||||
this.isProcessingQueue = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ class Logger {
|
|||
|
||||
constructor() {
|
||||
// __DEV__ is a global variable in React Native
|
||||
this.isEnabled = false;
|
||||
this.isEnabled = __DEV__;
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue