Merge branch 'NuvioMedia:main' into localization-patch

This commit is contained in:
albyalex96 2026-03-27 15:22:02 +01:00 committed by GitHub
commit f5c1219550
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 806 additions and 709 deletions

View file

@ -10,3 +10,12 @@ export const CACHE_DURATION = 5 * 60 * 1000;
export const TRAKT_SYNC_COOLDOWN = 0; export const TRAKT_SYNC_COOLDOWN = 0;
export const SIMKL_SYNC_COOLDOWN = 0; export const SIMKL_SYNC_COOLDOWN = 0;
export const TRAKT_RECONCILE_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;

View file

@ -5,7 +5,7 @@ import { storageService } from '../../../services/storageService';
import { stremioService } from '../../../services/stremioService'; import { stremioService } from '../../../services/stremioService';
import { TraktContentData } from '../../../services/traktService'; import { TraktContentData } from '../../../services/traktService';
import { CACHE_DURATION } from './constants'; import { CACHE_DURATION, CW_NEXT_UP_NEW_SEASON_UNAIRED_WINDOW_DAYS } from './constants';
import { import {
CachedMetadataEntry, CachedMetadataEntry,
GetCachedMetadata, GetCachedMetadata,
@ -133,7 +133,8 @@ export function findNextEpisode(
watchedSet?: Set<string>, watchedSet?: Set<string>,
showId?: string, showId?: string,
localWatchedMap?: Map<string, number>, localWatchedMap?: Map<string, number>,
baseTimestamp: number = 0 baseTimestamp: number = 0,
showUnairedNextUp: boolean = true
): { video: any; lastWatched: number } | null { ): { video: any; lastWatched: number } | null {
if (!videos || !Array.isArray(videos)) return null; if (!videos || !Array.isArray(videos)) return null;
@ -170,12 +171,48 @@ export function findNextEpisode(
return false; return false;
}; };
const now = new Date();
const todayMs = now.getTime();
for (const video of sortedVideos) { for (const video of sortedVideos) {
if (video.season < currentSeason) continue; if (video.season < currentSeason) continue;
if (video.season === currentSeason && video.episode <= currentEpisode) continue; if (video.season === currentSeason && video.episode <= currentEpisode) continue;
if (isAlreadyWatched(video.season, video.episode)) 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 }; return { video, lastWatched: latestWatchedTimestamp };
} }
} }

View file

@ -7,10 +7,18 @@ import {
} from '../../../services/traktService'; } from '../../../services/traktService';
import { logger } from '../../../utils/logger'; 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 { GetCachedMetadata, LocalProgressEntry } from './dataTypes';
import { import {
buildTraktContentData,
filterRemovedItems, filterRemovedItems,
findNextEpisode, findNextEpisode,
getHighestLocalMatch, getHighestLocalMatch,
@ -18,7 +26,6 @@ import {
getMostRecentLocalMatch, getMostRecentLocalMatch,
} from './dataShared'; } from './dataShared';
import { ContinueWatchingItem } from './types'; import { ContinueWatchingItem } from './types';
import { compareContinueWatchingItems } from './utils';
interface MergeTraktContinueWatchingParams { interface MergeTraktContinueWatchingParams {
traktService: TraktService; traktService: TraktService;
@ -31,6 +38,23 @@ interface MergeTraktContinueWatchingParams {
setContinueWatchingItems: Dispatch<SetStateAction<ContinueWatchingItem[]>>; 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({ export async function mergeTraktContinueWatching({
traktService, traktService,
getCachedMetadata, getCachedMetadata,
@ -41,6 +65,16 @@ export async function mergeTraktContinueWatching({
lastTraktReconcileRef, lastTraktReconcileRef,
setContinueWatchingItems, setContinueWatchingItems,
}: MergeTraktContinueWatchingParams): Promise<void> { }: 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(); const now = Date.now();
if ( if (
TRAKT_SYNC_COOLDOWN > 0 && TRAKT_SYNC_COOLDOWN > 0 &&
@ -53,177 +87,311 @@ export async function mergeTraktContinueWatching({
} }
lastTraktSyncRef.current = now; 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 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>>(); const watchedEpisodeSetByShow = new Map<string, Set<string>>();
try { try {
watchedShowsData = await traktService.getWatchedShows();
for (const watchedShow of watchedShowsData) { for (const watchedShow of watchedShowsData) {
if (!watchedShow.show?.ids?.imdb) continue; if (!watchedShow.show?.ids?.imdb) continue;
const imdb = watchedShow.show.ids.imdb.startsWith('tt') const imdb = watchedShow.show.ids.imdb.startsWith('tt')
? watchedShow.show.ids.imdb ? watchedShow.show.ids.imdb
: `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>(); const episodeSet = new Set<string>();
if (watchedShow.seasons) { if (watchedShow.seasons) {
for (const season of watchedShow.seasons) { for (const season of watchedShow.seasons) {
for (const episode of season.episodes) { for (const episode of season.episodes) {
if (resetAt > 0) { if (resetAt > 0) {
const watchedAt = new Date(episode.last_watched_at).getTime(); const watchedAt = getValidTime(episode.last_watched_at);
if (watchedAt < resetAt) continue; if (watchedAt < resetAt) continue;
} }
episodeSet.add(`${imdb}:${season.number}:${episode.number}`); episodeSet.add(`${imdb}:${season.number}:${episode.number}`);
} }
} }
} }
watchedEpisodeSetByShow.set(imdb, episodeSet); watchedEpisodeSetByShow.set(imdb, episodeSet);
} }
} catch (err) {
logger.warn('[TraktSync] Error mapping watched shows:', err);
}
// ─── 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 daysCutoff = Date.now() - (CW_DEFAULT_DAYS_CAP * 24 * 60 * 60 * 1000);
// 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 { } catch {
// Continue without watched-show acceleration. // Skip bad items
}
} }
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); // 3b. Playback items (in-progress) — overwrite history entries for the same content
const sortedPlaybackItems = [...playbackItems] const sortedPlaybackItems = [...(playbackItems || [])]
.sort((a, b) => { .sort((a, b) => {
const getBaseTime = (item: any) => const timeA = getValidTime(a.paused_at || a.updated_at || a.last_watched_at);
new Date( const timeB = getValidTime(b.paused_at || b.updated_at || b.last_watched_at);
item.paused_at || return timeB - timeA;
item.updated_at ||
item.last_watched_at ||
0
).getTime();
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;
};
return getPriorityTime(b) - getPriorityTime(a);
}) })
.slice(0, 30); .slice(0, CW_MAX_RECENT_PROGRESS_ITEMS);
for (const item of sortedPlaybackItems) { for (const item of sortedPlaybackItems) {
try { try {
if (item.progress < 2) continue; if (item.progress < 2) continue;
const pausedAt = new Date(item.paused_at).getTime(); const pausedAt = getValidTime(item.paused_at || item.updated_at);
if (pausedAt < thirtyDaysAgo) continue; if (pausedAt > 0 && pausedAt < daysCutoff) continue;
if (item.type === 'movie' && item.movie?.ids?.imdb) { if (item.type === 'movie' && item.movie?.ids?.imdb) {
if (item.progress >= 85) continue;
const imdbId = item.movie.ids.imdb.startsWith('tt') const imdbId = item.movie.ids.imdb.startsWith('tt')
? item.movie.ids.imdb ? item.movie.ids.imdb
: `tt${item.movie.ids.imdb}`; : `tt${item.movie.ids.imdb}`;
if (recentlyRemoved.has(`movie:${imdbId}`)) continue; const key = imdbId;
mergedByKey.set(key, {
const cachedData = await getCachedMetadata('movie', imdbId); contentId: imdbId,
if (!cachedData?.basicContent) continue; contentType: 'movie',
progressPercent: item.progress,
traktBatch.push({ lastWatched: pausedAt,
...cachedData.basicContent, source: 'playback',
id: imdbId,
type: 'movie',
progress: item.progress,
lastUpdated: pausedAt,
addonId: undefined,
traktPlaybackId: item.id, traktPlaybackId: item.id,
} as ContinueWatchingItem); });
} else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) { } else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) {
const showImdb = item.show.ids.imdb.startsWith('tt') const showImdb = item.show.ids.imdb.startsWith('tt')
? item.show.ids.imdb ? item.show.ids.imdb
: `tt${item.show.ids.imdb}`; : `tt${item.show.ids.imdb}`;
if (recentlyRemoved.has(`series:${showImdb}`)) continue; const key = showImdb;
mergedByKey.set(key, {
const cachedData = await getCachedMetadata('series', showImdb); contentId: showImdb,
if (!cachedData?.basicContent) continue; contentType: 'series',
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,
season: item.episode.season, season: item.episode.season,
episode: item.episode.number, episode: item.episode.number,
episodeTitle: item.episode.title || `Episode ${item.episode.number}`, episodeTitle: item.episode.title,
addonId: undefined, progressPercent: item.progress,
lastWatched: pausedAt,
source: 'playback',
traktPlaybackId: item.id, traktPlaybackId: item.id,
} as ContinueWatchingItem); });
} }
} catch { } catch {
// Continue with remaining playback items. // Continue with remaining playback items.
} }
} }
try { // ─── 4. Sort merged items by lastWatched and apply cap ───
const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000); 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 { try {
if (!watchedShow.show?.ids?.imdb) continue; 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') const showImdb = watchedShow.show.ids.imdb.startsWith('tt')
? watchedShow.show.ids.imdb ? watchedShow.show.ids.imdb
: `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; 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 lastWatchedSeason = 0;
let lastWatchedEpisode = 0; let lastWatchedEpisode = 0;
let latestEpisodeTimestamp = 0; let latestEpisodeTimestamp = 0;
@ -231,7 +399,7 @@ export async function mergeTraktContinueWatching({
if (watchedShow.seasons) { if (watchedShow.seasons) {
for (const season of watchedShow.seasons) { for (const season of watchedShow.seasons) {
for (const episode of season.episodes) { 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 (resetAt > 0 && episodeTimestamp < resetAt) continue;
if (episodeTimestamp > latestEpisodeTimestamp) { if (episodeTimestamp > latestEpisodeTimestamp) {
@ -245,47 +413,118 @@ export async function mergeTraktContinueWatching({
if (lastWatchedSeason === 0 && lastWatchedEpisode === 0) continue; if (lastWatchedSeason === 0 && lastWatchedEpisode === 0) continue;
const cachedData = await getCachedMetadata('series', showImdb); nextUpSeeds.push({
if (!cachedData?.basicContent || !cachedData.metadata?.videos) continue; contentId: showImdb,
contentType: 'series',
const watchedEpisodeSet = watchedEpisodeSetByShow.get(showImdb) ?? new Set<string>(); season: lastWatchedSeason,
const localWatchedMap = await localWatchedShowsMapPromise; episode: lastWatchedEpisode,
const nextEpisodeResult = findNextEpisode( progressPercent: 100,
lastWatchedSeason, lastWatched: latestEpisodeTimestamp,
lastWatchedEpisode, source: 'watched_show',
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);
}
} catch { } catch {
// Continue with remaining watched shows. // Continue with remaining watched shows.
} }
} }
} catch (err) { } 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) { if (traktBatch.length === 0) {
logger.log('[TraktCW] No items — clearing continue watching list');
setContinueWatchingItems([]);
return; return;
} }
// Deduplicate: for same content, prefer items with progress > 0 (in-progress over up-next)
const deduped = new Map<string, ContinueWatchingItem>(); const deduped = new Map<string, ContinueWatchingItem>();
for (const item of traktBatch) { for (const item of traktBatch) {
const key = `${item.type}:${item.id}`; const key = `${item.type}:${item.id}`;
@ -299,25 +538,27 @@ export async function mergeTraktContinueWatching({
const existingHasProgress = (existing.progress ?? 0) > 0; const existingHasProgress = (existing.progress ?? 0) > 0;
const candidateHasProgress = (item.progress ?? 0) > 0; const candidateHasProgress = (item.progress ?? 0) > 0;
const safeItemTs = getValidTime(item.lastUpdated);
const safeExistingTs = getValidTime(existing.lastUpdated);
if (candidateHasProgress && !existingHasProgress) { if (candidateHasProgress && !existingHasProgress) {
const mergedTs = Math.max(item.lastUpdated ?? 0, existing.lastUpdated ?? 0); const mergedTs = Math.max(safeItemTs, safeExistingTs);
deduped.set( deduped.set(
key, key,
mergedTs !== (item.lastUpdated ?? 0) mergedTs !== safeItemTs
? { ...item, lastUpdated: mergedTs } ? { ...item, lastUpdated: mergedTs }
: item : item
); );
} else if (!candidateHasProgress && existingHasProgress) { } else if (!candidateHasProgress && existingHasProgress) {
if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { if (safeItemTs > safeExistingTs) {
deduped.set(key, { ...existing, lastUpdated: item.lastUpdated }); deduped.set(key, { ...existing, lastUpdated: safeItemTs });
} }
} else if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { } else if (safeItemTs > safeExistingTs) {
deduped.set(key, item); deduped.set(key, item);
} }
} }
const filteredItems = await filterRemovedItems(Array.from(deduped.values()), recentlyRemoved); const filteredItems = await filterRemovedItems(Array.from(deduped.values()), recentlyRemoved);
const reconcilePromises: Promise<any>[] = [];
const reconcileLocalPromises: Promise<any>[] = []; const reconcileLocalPromises: Promise<any>[] = [];
const adjustedItems = filteredItems const adjustedItems = filteredItems
@ -332,14 +573,14 @@ export async function mergeTraktContinueWatching({
return item; return item;
} }
const mergedLastUpdated = Math.max( // Use getValidTime for safe timestamp extraction
mostRecentLocal.lastUpdated ?? 0, const safeLocalTs = getValidTime(mostRecentLocal.lastUpdated);
item.lastUpdated ?? 0 const safeItemTs = getValidTime(item.lastUpdated);
);
const localProgress = mostRecentLocal.progressPercent; const localProgress = mostRecentLocal.progressPercent;
const traktProgress = item.progress ?? 0; const traktProgress = item.progress ?? 0;
const traktTs = item.lastUpdated ?? 0; const traktTs = safeItemTs;
const localTs = mostRecentLocal.lastUpdated ?? 0; const localTs = safeLocalTs;
const isAhead = isFinite(localProgress) && localProgress > traktProgress + 0.5; const isAhead = isFinite(localProgress) && localProgress > traktProgress + 0.5;
const isLocalNewer = localTs > traktTs + 5000; const isLocalNewer = localTs > traktTs + 5000;
@ -401,49 +642,47 @@ export async function mergeTraktContinueWatching({
} }
} }
if ((isAhead || ((isLocalNewer || isLocalRecent) && isDifferent)) && localProgress >= 2) { // If Trakt says in-progress (2-85%) but local says completed (>=85%),
const reconcileKey = `${item.type}:${item.id}:${item.season ?? ''}:${item.episode ?? ''}`; // trust Trakt's playback endpoint — it's authoritative for paused items.
const last = lastTraktReconcileRef.current.get(reconcileKey) ?? 0; const traktIsInProgress = traktProgress >= 2 && traktProgress < 85;
const now = Date.now(); const localSaysCompleted = localProgress >= 85;
if (traktIsInProgress && localSaysCompleted) {
if (now - last >= TRAKT_RECONCILE_COOLDOWN) { return {
lastTraktReconcileRef.current.set(reconcileKey, now); ...item,
lastUpdated: safeItemTs,
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 (((isLocalNewer || isLocalRecent) && isDifferent) || isAhead) { if (((isLocalNewer || isLocalRecent) && isDifferent) || isAhead) {
return { return {
...item, ...item,
progress: localProgress, progress: localProgress,
lastUpdated: mergedLastUpdated, lastUpdated: safeItemTs, // keep Trakt timestamp, only update progress
}; };
} }
return { return {
...item, ...item,
lastUpdated: mergedLastUpdated, lastUpdated: safeItemTs, // keep Trakt timestamp for sort stability
}; };
}) });
.filter((item) => (item.progress ?? 0) < 85);
adjustedItems.sort(compareContinueWatchingItems); const finalItems = adjustedItems
setContinueWatchingItems(adjustedItems); .sort((a, b) => getValidTime(b.lastUpdated) - getValidTime(a.lastUpdated))
.slice(0, CW_MAX_DISPLAY_ITEMS);
if (reconcilePromises.length > 0) { logger.log(`[TraktCW] ═══ FINAL LIST: ${finalItems.length} items (capped at ${CW_MAX_DISPLAY_ITEMS}) ═══`);
Promise.allSettled(reconcilePromises).catch(() => null); 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) { if (reconcileLocalPromises.length > 0) {
Promise.allSettled(reconcileLocalPromises).catch(() => null); Promise.allSettled(reconcileLocalPromises).catch(() => null);

View file

@ -209,6 +209,8 @@ export function useContinueWatchingData() {
const simklService = SimklService.getInstance(); const simklService = SimklService.getInstance();
const isSimklAuthed = !isTraktAuthed ? await simklService.isAuthenticated() : false; const isSimklAuthed = !isTraktAuthed ? await simklService.isAuthenticated() : false;
console.log(`[CW-Hook] Auth state: trakt=${isTraktAuthed} simkl=${isSimklAuthed}`);
const traktMoviesSetPromise = getTraktMoviesSet(isTraktAuthed, traktService); const traktMoviesSetPromise = getTraktMoviesSet(isTraktAuthed, traktService);
const traktShowsSetPromise = getTraktShowsSet(isTraktAuthed, traktService); const traktShowsSetPromise = getTraktShowsSet(isTraktAuthed, traktService);
const localWatchedShowsMapPromise = getLocalWatchedShowsMap(); const localWatchedShowsMapPromise = getLocalWatchedShowsMap();
@ -239,7 +241,7 @@ export function useContinueWatchingData() {
await Promise.allSettled([ await Promise.allSettled([
isTraktAuthed isTraktAuthed
? mergeTraktContinueWatching({ ? (console.log('[CW-Hook] Calling mergeTraktContinueWatching...'), mergeTraktContinueWatching({
traktService, traktService,
getCachedMetadata, getCachedMetadata,
localProgressIndex, localProgressIndex,
@ -248,8 +250,8 @@ export function useContinueWatchingData() {
lastTraktSyncRef, lastTraktSyncRef,
lastTraktReconcileRef, lastTraktReconcileRef,
setContinueWatchingItems, setContinueWatchingItems,
}) }))
: Promise.resolve(), : (console.log('[CW-Hook] Trakt NOT authed, skipping merge'), Promise.resolve()),
isSimklAuthed && !isTraktAuthed isSimklAuthed && !isTraktAuthed
? mergeSimklContinueWatching({ ? mergeSimklContinueWatching({
simklService, simklService,

View file

@ -271,8 +271,6 @@ const AndroidVideoPlayer: React.FC = () => {
const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId); 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({ const { segments: skipIntervals, outroSegment } = useSkipSegments({
imdbId: resolvedImdbId || (id?.startsWith('tt') ? id : undefined), imdbId: resolvedImdbId || (id?.startsWith('tt') ? id : undefined),
type, type,
@ -490,6 +488,10 @@ const AndroidVideoPlayer: React.FC = () => {
} }
}, 300); }, 300);
} }
if (videoDuration > 0) {
traktAutosync.handlePlaybackStart(0, videoDuration);
}
}, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]); }, [id, type, episodeId, playerState.isMounted, watchProgress.initialPosition, useExoPlayer]);
const handleProgress = useCallback((data: any) => { const handleProgress = useCallback((data: any) => {
@ -945,7 +947,7 @@ const AndroidVideoPlayer: React.FC = () => {
addonId: currentStreamProvider addonId: currentStreamProvider
}, episodeId); }, episodeId);
} }
traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true); traktAutosync.handlePlaybackStart(data.currentTime, playerState.duration);
} }
}} }}
onEnd={() => { onEnd={() => {

View file

@ -242,6 +242,7 @@ const KSPlayerCore: React.FC = () => {
duration, duration,
lastUpdated: Date.now() lastUpdated: Date.now()
}, episodeId); }, episodeId);
traktAutosync.handlePlaybackStart(timeInSeconds, duration);
} }
}); });
@ -650,9 +651,6 @@ const KSPlayerCore: React.FC = () => {
if (isSyncingBeforeClose.current) return; if (isSyncingBeforeClose.current) return;
isSyncingBeforeClose.current = true; 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'); traktAutosync.handlePlaybackEnd(currentTime, duration, 'user_close');
navigation.goBack(); navigation.goBack();

View file

@ -162,7 +162,6 @@ export const useWatchProgress = (
}; };
try { try {
await storageService.setWatchProgress(id, type, progress, episodeId); await storageService.setWatchProgress(id, type, progress, episodeId);
await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current);
// Requirement 1: Auto Episode Tracking (>= 90% completion) // Requirement 1: Auto Episode Tracking (>= 90% completion)
const progressPercent = (currentTimeRef.current / durationRef.current) * 100; const progressPercent = (currentTimeRef.current / durationRef.current) * 100;
@ -204,20 +203,24 @@ export const useWatchProgress = (
useEffect(() => { useEffect(() => {
// Handle pause transitions (upstream)
if (wasPausedRef.current !== paused) { if (wasPausedRef.current !== paused) {
const becamePaused = paused; const becamePaused = paused;
wasPausedRef.current = paused; wasPausedRef.current = paused;
if (becamePaused) { if (becamePaused) {
void saveWatchProgress(); 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 (id && type && !paused) {
if (progressSaveInterval) clearInterval(progressSaveInterval); if (progressSaveInterval) clearInterval(progressSaveInterval);
// Use refs inside the interval so we don't need to restart it on every second
const interval = setInterval(() => { const interval = setInterval(() => {
saveWatchProgress(); saveWatchProgress();
}, 10000); }, 10000);
@ -238,7 +241,8 @@ export const useWatchProgress = (
setTimeout(() => { setTimeout(() => {
if (id && type && durationRef.current > 0) { if (id && type && durationRef.current > 0) {
saveWatchProgress(); 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); }, 0);
}; };

View file

@ -167,6 +167,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const [addonResponseOrder, setAddonResponseOrder] = useState<string[]>([]); const [addonResponseOrder, setAddonResponseOrder] = useState<string[]>([]);
// Prevent re-initializing season selection repeatedly for the same series // Prevent re-initializing season selection repeatedly for the same series
const initializedSeasonRef = useRef(false); 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) // Memory optimization: Track stream counts and implement cleanup (limits removed)
const streamCountRef = useRef(0); 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), // If normalizedType is not a known type (e.g. "other" from Gemini/AI search),
// resolve the correct type via TMDB before fetching addon metadata. // resolve the correct type via TMDB before fetching addon metadata.
let effectiveType = normalizedType; let effectiveType = normalizedType;
resolvedTypeRef.current = normalizedType; // reset each load
if (normalizedType !== 'movie' && normalizedType !== 'series') { if (normalizedType !== 'movie' && normalizedType !== 'series') {
try { try {
if (actualId.startsWith('tt')) { if (actualId.startsWith('tt')) {
@ -734,6 +736,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const resolved = await tmdbSvc.findTypeAndIdByIMDB(actualId); const resolved = await tmdbSvc.findTypeAndIdByIMDB(actualId);
if (resolved) { if (resolved) {
effectiveType = resolved.type; effectiveType = resolved.type;
resolvedTypeRef.current = resolved.type;
setTmdbId(resolved.tmdbId); setTmdbId(resolved.tmdbId);
if (__DEV__) console.log(`🔍 [useMetadata] Resolved unknown type "${normalizedType}" → "${effectiveType}" via TMDB /find`); 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) // Prefer series when both exist (anime/TV tagged as "other" is usually a series)
if (hasSeries) effectiveType = 'series'; if (hasSeries) effectiveType = 'series';
else if (hasMovie) effectiveType = 'movie'; else if (hasMovie) effectiveType = 'movie';
resolvedTypeRef.current = effectiveType;
if (__DEV__) console.log(`🔍 [useMetadata] Resolved unknown type "${normalizedType}" → "${effectiveType}" via TMDB parallel check`); 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); if (__DEV__) logger.log('🔍 [loadStreams] Getting TMDB ID for:', id);
let tmdbId; let tmdbId;
let stremioId = id; 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:')) { if (id.startsWith('tmdb:')) {
tmdbId = id.split(':')[1]; tmdbId = id.split(':')[1];
@ -1626,7 +1632,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const allStremioAddons = await stremioService.getInstalledAddons(); const allStremioAddons = await stremioService.getInstalledAddons();
const localScrapers = await localScraperService.getInstalledScrapers(); 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) => const pickEligibleStreamAddons = (requestType: string) =>
allStremioAddons.filter(addon => { allStremioAddons.filter(addon => {

View file

@ -7,27 +7,39 @@ import { SimklContentData } from '../services/simklService';
import { storageService } from '../services/storageService'; import { storageService } from '../services/storageService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
const TRAKT_SCROBBLE_THRESHOLD = 80;
interface TraktAutosyncOptions { interface TraktAutosyncOptions {
id: string; id: string;
type: 'movie' | 'series'; type: 'movie' | 'series';
title: string; title: string;
year: number | string; // Allow both for compatibility year: number | string;
imdbId: string; imdbId: string;
// For episodes
season?: number; season?: number;
episode?: number; episode?: number;
showTitle?: string; showTitle?: string;
showYear?: number | string; // Allow both for compatibility showYear?: number | string;
showImdbId?: string; showImdbId?: string;
episodeId?: 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) { export function useTraktAutosync(options: TraktAutosyncOptions) {
const { const {
isAuthenticated, isAuthenticated,
startWatching, startWatching,
updateProgress,
updateProgressImmediate,
stopWatching, stopWatching,
stopWatchingImmediate stopWatchingImmediate
} = useTraktIntegration(); } = useTraktIntegration();
@ -41,108 +53,87 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
const { settings: autosyncSettings } = useTraktAutosyncSettings(); const { settings: autosyncSettings } = useTraktAutosyncSettings();
const hasStartedWatching = useRef(false); // Session state refs
const hasStopped = useRef(false); // New: Track if we've already stopped for this session const isSessionComplete = useRef(false); // True once scrobbled (>= 80%) — blocks ALL further payloads
const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled) const isUnmounted = useRef(false);
const isUnmounted = useRef(false); // New: Track if component has unmounted
const lastSyncTime = useRef(0);
const lastSyncProgress = useRef(0); const lastSyncProgress = useRef(0);
const sessionKey = useRef<string | null>(null); const sessionKey = useRef<string | null>(null);
const unmountCount = useRef(0); 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(() => { useEffect(() => {
const resolvedId = (options.imdbId && options.imdbId.trim()) ? options.imdbId : (options.id || ''); const contentKey = getContentKey(options);
const contentKey = options.type === 'movie'
? `movie:${resolvedId}`
: `episode:${options.showImdbId || resolvedId}:${options.season}:${options.episode}`;
sessionKey.current = `${contentKey}:${Date.now()}`; sessionKey.current = `${contentKey}:${Date.now()}`;
isUnmounted.current = false;
unmountCount.current = 0;
// Reset all session state for new content // Check if this content was recently scrobbled (prevents duplicate on remount)
hasStartedWatching.current = false; const prior = recentlyScrobbledSessions.get(contentKey);
hasStopped.current = false; 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; isSessionComplete.current = false;
isUnmounted.current = false; // Reset unmount flag for new mount lastSyncProgress.current = 0;
lastStopCall.current = 0; lastStopCall.current = 0;
if (prior) {
logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`); recentlyScrobbledSessions.delete(contentKey);
}
logger.log(`[TraktAutosync] New session started for: ${sessionKey.current}`);
}
return () => { return () => {
unmountCount.current++; 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}`); logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`);
}; };
}, [options.imdbId, options.season, options.episode, options.type]); }, [options.imdbId, options.season, options.episode, options.type]);
// Build Trakt content data from options // ── Build content data helpers ──────────────────────────────────────
// Returns null if required fields are missing or invalid
const buildContentData = useCallback((): TraktContentData | null => { const buildContentData = useCallback((): TraktContentData | null => {
// Parse and validate year - returns undefined for invalid/missing years
const parseYear = (year: number | string | undefined): number | undefined => { const parseYear = (year: number | string | undefined): number | undefined => {
if (year === undefined || year === null || year === '') return undefined; if (year === undefined || year === null || year === '') return undefined;
if (typeof year === 'number') { if (typeof year === 'number') {
// Year must be a reasonable value (between 1800 and current year + 10)
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
if (year <= 0 || year < 1800 || year > currentYear + 10) { if (year < 1800 || year > currentYear + 10) return undefined;
logger.warn(`[TraktAutosync] Invalid year value: ${year}`);
return undefined;
}
return year; return year;
} }
const parsed = parseInt(year.toString(), 10); const parsed = parseInt(year.toString(), 10);
if (isNaN(parsed) || parsed <= 0) { if (isNaN(parsed) || parsed <= 0) return undefined;
logger.warn(`[TraktAutosync] Failed to parse year: ${year}`);
return undefined;
}
// Validate parsed year range
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
if (parsed < 1800 || parsed > currentYear + 10) { if (parsed < 1800 || parsed > currentYear + 10) return undefined;
logger.warn(`[TraktAutosync] Year out of valid range: ${parsed}`);
return undefined;
}
return parsed; return parsed;
}; };
// Validate required fields early
if (!options.title || options.title.trim() === '') { 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; 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 imdbIdRaw = options.imdbId && options.imdbId.trim() ? options.imdbId.trim() : '';
const stremioIdRaw = options.id && options.id.trim() ? options.id.trim() : ''; const stremioIdRaw = options.id && options.id.trim() ? options.id.trim() : '';
const resolvedImdbId = imdbIdRaw || stremioIdRaw; const resolvedImdbId = imdbIdRaw || stremioIdRaw;
if (!resolvedImdbId) { 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; 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 numericYear = parseYear(options.year);
const numericShowYear = parseYear(options.showYear); 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') { if (options.type === 'movie') {
return { return {
type: 'movie', type: 'movie',
imdbId: resolvedImdbId, imdbId: resolvedImdbId,
title: options.title.trim(), title: options.title.trim(),
year: numericYear // Can be undefined now year: numericYear
}; };
} else { } else {
// For episodes, also validate season and episode numbers
if (options.season === undefined || options.season === null || options.season < 0) { if (options.season === undefined || options.season === null || options.season < 0) {
logger.error('[TraktAutosync] Cannot build episode content data: invalid season'); logger.error('[TraktAutosync] Cannot build episode content data: invalid season');
return null; return null;
@ -171,422 +162,160 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
}, [options]); }, [options]);
const buildSimklContentData = useCallback((): SimklContentData => { const buildSimklContentData = useCallback((): SimklContentData => {
// Use the same fallback logic: prefer imdbId, fall back to stremio id
const resolvedId = (options.imdbId && options.imdbId.trim()) const resolvedId = (options.imdbId && options.imdbId.trim())
? options.imdbId.trim() ? options.imdbId.trim()
: (options.id && options.id.trim()) ? options.id.trim() : ''; : (options.id && options.id.trim()) ? options.id.trim() : '';
return { return {
type: options.type === 'series' ? 'episode' : 'movie', type: options.type === 'series' ? 'episode' : 'movie',
title: options.title, title: options.title,
ids: { ids: { imdb: resolvedId },
imdb: resolvedId
},
season: options.season, season: options.season,
episode: options.episode episode: options.episode
}; };
}, [options]); }, [options]);
// Start watching (scrobble start) // ── /scrobble/start — play, unpause, seek ──────────────────────────
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
if (isUnmounted.current) return; // Prevent execution after component unmount
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 shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
const shouldSyncSimkl = isSimklAuthenticated; const shouldSyncSimkl = isSimklAuthenticated;
if (!shouldSyncTrakt && !shouldSyncSimkl) { if (!shouldSyncTrakt && !shouldSyncSimkl) return;
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: Trakt (auth=${isAuthenticated}, enabled=${autosyncSettings.enabled}), Simkl (auth=${isSimklAuthenticated})`);
return;
}
// PREVENT SESSION RESTART: Don't start if session is complete (scrobbled) // After scrobble (>= 80%), send NO more payloads
if (isSessionComplete.current) { if (isSessionComplete.current) {
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session is complete, preventing any restart`); logger.log(`[TraktAutosync] Session complete — skipping /scrobble/start`);
return; return;
} }
// PREVENT SESSION RESTART: Don't start if we've already stopped this session if (duration <= 0) return;
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;
}
try { try {
// Clamp progress between 0 and 100
const rawProgress = (currentTime / duration) * 100; const rawProgress = (currentTime / duration) * 100;
const progressPercent = Math.min(100, Math.max(0, rawProgress)); const progressPercent = Math.min(100, Math.max(0, rawProgress));
const contentData = buildContentData();
// Skip if content data is invalid // If we're already past 80%, don't send start — it's already scrobbled or will be
if (!contentData) { if (progressPercent >= TRAKT_SCROBBLE_THRESHOLD) {
logger.warn('[TraktAutosync] Skipping start: invalid content data'); logger.log(`[TraktAutosync] Progress ${progressPercent.toFixed(1)}% >= ${TRAKT_SCROBBLE_THRESHOLD}%, skipping start`);
return; return;
} }
const contentData = buildContentData();
if (!contentData) return;
if (shouldSyncTrakt) { if (shouldSyncTrakt) {
const success = await startWatching(contentData, progressPercent); const success = await startWatching(contentData, progressPercent);
if (success) { if (success) {
hasStartedWatching.current = true; lastSyncProgress.current = progressPercent;
hasStopped.current = false; // Reset stop flag when starting logger.log(`[TraktAutosync] /scrobble/start sent: ${contentData.title} at ${progressPercent.toFixed(1)}%`);
logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
} }
} 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) { if (shouldSyncSimkl) {
const simklData = buildSimklContentData(); const simklData = buildSimklContentData();
await startSimkl(simklData, progressPercent); await startSimkl(simklData, progressPercent);
} }
} catch (error) { } catch (error) {
logger.error('[TraktAutosync] Error starting watch:', error); logger.error('[TraktAutosync] Error in handlePlaybackStart:', error);
} }
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, startWatching, startSimkl, buildContentData, buildSimklContentData]); }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, startWatching, startSimkl, buildContentData, buildSimklContentData]);
// Sync progress during playback // ── /scrobble/stop — pause, close, unmount, video end ──────────────
const handleProgressUpdate = useCallback(async (
currentTime: number,
duration: number,
force: boolean = false
) => {
if (isUnmounted.current) return; // Prevent execution after component unmount
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') => { 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(); const now = Date.now();
// Removed excessive logging for handlePlaybackEnd calls
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled; const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
const shouldSyncSimkl = isSimklAuthenticated; const shouldSyncSimkl = isSimklAuthenticated;
if (!shouldSyncTrakt && !shouldSyncSimkl) { if (!shouldSyncTrakt && !shouldSyncSimkl) return;
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: Neither Trakt nor Simkl are active.`);
return;
}
// ENHANCED DEDUPLICATION: Check if session is already complete // After scrobble (>= 80%), send NO more payloads — prevents duplicate entries
if (isSessionComplete.current) { 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; return;
} }
// ENHANCED DEDUPLICATION: Check if we've already stopped this session // Debounce: prevent duplicate stop calls within 500ms
// However, allow updates if the new progress is significantly higher (>5% improvement) if (now - lastStopCall.current < 500) {
let isSignificantUpdate = false; logger.log(`[TraktAutosync] Ignoring duplicate stop call within 500ms (reason: ${reason})`);
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})`);
return; return;
} }
// Skip rapid unmount calls (likely from React strict mode or component remounts) // Skip duplicate unmount calls (React strict mode)
if (reason === 'unmount' && unmountCount.current > 1) { if (reason === 'unmount' && unmountCount.current > 1) return;
logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`);
return;
}
try { try {
let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
// Clamp progress between 0 and 100
progressPercent = Math.min(100, Math.max(0, progressPercent)); progressPercent = Math.min(100, Math.max(0, progressPercent));
// Initial progress calculation logging removed
// For unmount calls, always use the highest available progress // For unmount, use highest known progress
// Check current progress, last synced progress, and local storage progress
if (reason === 'unmount') { if (reason === 'unmount') {
let maxProgress = progressPercent; if (lastSyncProgress.current > progressPercent) {
progressPercent = lastSyncProgress.current;
// Check last synced progress
if (lastSyncProgress.current > maxProgress) {
maxProgress = lastSyncProgress.current;
} }
// Also check local storage for the highest recorded progress
try { try {
const savedProgress = await storageService.getWatchProgress( const savedProgress = await storageService.getWatchProgress(options.id, options.type, options.episodeId);
options.id,
options.type,
options.episodeId
);
if (savedProgress && savedProgress.duration > 0) { if (savedProgress && savedProgress.duration > 0) {
const savedProgressPercent = Math.min(100, Math.max(0, (savedProgress.currentTime / savedProgress.duration) * 100)); const savedPercent = Math.min(100, Math.max(0, (savedProgress.currentTime / savedProgress.duration) * 100));
if (savedProgressPercent > maxProgress) { if (savedPercent > progressPercent) progressPercent = savedPercent;
maxProgress = savedProgressPercent;
} }
} } catch {}
} catch (error) {
logger.error('[TraktAutosync] Error checking saved progress:', error);
} }
if (maxProgress !== progressPercent) { // Trakt ignores progress < 1% (returns 422)
// Highest progress logging removed if (progressPercent < 1) {
progressPercent = maxProgress; logger.log(`[TraktAutosync] Progress ${progressPercent.toFixed(1)}% < 1%, skipping stop`);
} else {
// Current progress logging removed
}
}
// 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)}%)`);
return; return;
} }
// Note: No longer boosting progress since Trakt API handles 80% threshold correctly
// Mark stop attempt and update timestamp
lastStopCall.current = now; lastStopCall.current = now;
hasStopped.current = true; lastSyncProgress.current = progressPercent;
const contentData = buildContentData(); const contentData = buildContentData();
if (!contentData) return;
// Skip if content data is invalid // Send /scrobble/stop to Trakt
if (!contentData) { // Trakt API: >= 80% → scrobble (marks watched), 1-79% → pause (saves progress)
logger.warn('[TraktAutosync] Skipping stop: invalid content data'); let traktSuccess = false;
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;
if (shouldSyncTrakt) { if (shouldSyncTrakt) {
traktStopSuccess = useImmediate const useImmediate = reason === 'user_close';
traktSuccess = useImmediate
? await stopWatchingImmediate(contentData, progressPercent) ? await stopWatchingImmediate(contentData, progressPercent)
: await stopWatching(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) { if (traktSuccess) {
// Update local storage sync status for Trakt logger.log(`[TraktAutosync] /scrobble/stop sent: ${contentData.title} at ${progressPercent.toFixed(1)}% (${reason})`);
await storageService.updateTraktSyncStatus( await storageService.updateTraktSyncStatus(
options.id, options.id, options.type, true, progressPercent, options.episodeId, currentTime
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`);
}
// Simkl Stop // If >= 80%, Trakt has scrobbled it — mark session complete, no more payloads
if (shouldSyncSimkl) { if (progressPercent >= TRAKT_SCROBBLE_THRESHOLD) {
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
);
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)
}
if (overallSuccess) {
// Mark session as complete if >= user completion threshold
if (progressPercent >= autosyncSettings.completionThreshold) {
isSessionComplete.current = true; isSessionComplete.current = true;
logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`); recentlyScrobbledSessions.set(getContentKey(options), {
scrobbledAt: now,
progress: progressPercent
});
logger.log(`[TraktAutosync] Scrobbled at ${progressPercent.toFixed(1)}% — session complete, no more payloads`);
// Ensure local watch progress reflects completion so UI shows as watched // Update local storage to reflect watched status
try { try {
if (duration > 0) { if (duration > 0) {
await storageService.setWatchProgress( await storageService.setWatchProgress(
options.id, options.id, options.type,
options.type,
{ {
currentTime: duration, currentTime: duration,
duration, duration,
lastUpdated: Date.now(), lastUpdated: Date.now(),
traktSynced: shouldSyncTrakt ? true : undefined, traktSynced: true,
traktProgress: shouldSyncTrakt ? Math.max(progressPercent, 100) : undefined, traktProgress: Math.max(progressPercent, 100),
simklSynced: shouldSyncSimkl ? true : undefined, simklSynced: shouldSyncSimkl ? true : undefined,
simklProgress: shouldSyncSimkl ? Math.max(progressPercent, 100) : undefined, simklProgress: shouldSyncSimkl ? Math.max(progressPercent, 100) : undefined,
} as any, } as any,
@ -596,43 +325,56 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
} }
} catch {} } 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 { } else {
// If neither service succeeded, reset the stop flag logger.warn(`[TraktAutosync] Failed to send /scrobble/stop`);
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 // Simkl Stop
if (reason === 'ended' || progressPercent >= 80) { if (shouldSyncSimkl) {
hasStartedWatching.current = false; const simklData = buildSimklContentData();
lastSyncTime.current = 0; await stopSimkl(simklData, progressPercent);
lastSyncProgress.current = 0; await storageService.updateSimklSyncStatus(
logger.log(`[TraktAutosync] Reset session state for ${reason} at ${progressPercent.toFixed(1)}%`); options.id, options.type, true, progressPercent, options.episodeId
);
logger.log(`[TraktAutosync] Simkl stop sent: ${simklData.title} at ${progressPercent.toFixed(1)}%`);
} }
} catch (error) { } catch (error) {
logger.error('[TraktAutosync] Error ending watch:', error); logger.error('[TraktAutosync] Error in handlePlaybackEnd:', error);
// Reset stop flag on error so we can try again
hasStopped.current = false;
} }
}, [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(() => { const resetState = useCallback(() => {
hasStartedWatching.current = false;
hasStopped.current = false;
isSessionComplete.current = false; isSessionComplete.current = false;
isUnmounted.current = false; isUnmounted.current = false;
lastSyncTime.current = 0;
lastSyncProgress.current = 0; lastSyncProgress.current = 0;
unmountCount.current = 0; unmountCount.current = 0;
sessionKey.current = null; sessionKey.current = null;
lastStopCall.current = 0; lastStopCall.current = 0;
recentlyScrobbledSessions.delete(getContentKey(options));
logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`); logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`);
}, [options.title]); }, [options.title]);

View file

@ -16,7 +16,7 @@ export interface TraktAutosyncSettings {
const DEFAULT_SETTINGS: TraktAutosyncSettings = { const DEFAULT_SETTINGS: TraktAutosyncSettings = {
enabled: true, enabled: true,
syncFrequency: 60000, // 60 seconds syncFrequency: 60000, // 60 seconds
completionThreshold: 95, // 95% completionThreshold: 80, // 80% — Trakt API hardcoded threshold
}; };
export function useTraktAutosyncSettings() { export function useTraktAutosyncSettings() {

View file

@ -325,12 +325,21 @@ async function searchAddonCatalog(
// meta addon by ID prefix matching. Setting it here causes 404s when two addons // 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. // are installed and one returns IDs the other can't serve metadata for.
const normalizedCatalogType = type ? type.toLowerCase() : type; // Always lowercase the item's own type first
if (normalizedCatalogType && content.type !== normalizedCatalogType) { if (content.type) {
content.type = normalizedCatalogType;
} else if (content.type) {
content.type = content.type.toLowerCase(); 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; return content;
}); });
@ -384,9 +393,17 @@ function dedupeAndStampResults(results: StreamingContent[], catalogType: string)
} }
} }
return Array.from(bestById.values()).map(item => const normalizedCatalogType = catalogType ? catalogType.toLowerCase() : catalogType;
catalogType && item.type !== catalogType ? { ...item, type: catalogType } : item 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( function buildSectionName(

View file

@ -1038,27 +1038,55 @@ class SupabaseSyncService {
videoId: string; videoId: string;
progressKey: string; progressKey: string;
} | null { } | null {
const parts = key.split(':'); // Key format from buildWpKeyString: "{type}:{contentId}" or "{type}:{contentId}:{episodeId}"
if (parts.length < 2) return null; // 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 typePart = key.substring(0, typeIdx);
const contentId = parts[1]; if (typePart !== 'movie' && typePart !== 'series') return null;
const episodeId = parts.length > 2 ? parts.slice(2).join(':') : ''; 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 season: number | null = null;
let episode: number | null = null; let episode: number | null = null;
const seMatch = episodeId.match(/:(\d+):(\d+)$/);
if (episodeId) { if (seMatch) {
const match = episodeId.match(/:(\d+):(\d+)$/); season = Number(seMatch[1]);
if (match) { episode = Number(seMatch[2]);
season = Number(match[1]);
episode = Number(match[2]);
}
} }
const videoId = episodeId || contentId; const videoId = episodeId || contentId;
const progressKey = contentType === 'movie' const progressKey = season != null && episode != null
? contentId ? `${contentId}_s${season}e${episode}`
: (season != null && episode != null ? `${contentId}_s${season}e${episode}` : `${contentId}_${videoId}`); : `${contentId}_${episodeId}`;
return { return {
contentType, contentType,
@ -1365,7 +1393,7 @@ class SupabaseSyncService {
const season = row.season == null ? null : Number(row.season); const season = row.season == null ? null : Number(row.season);
const episode = row.episode == null ? null : Number(row.episode); const episode = row.episode == null ? null : Number(row.episode);
const episodeId = type === 'series' && season != null && episode != null 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; : undefined;
remoteSet.add(this.buildLocalWatchProgressKey(type, row.content_id, episodeId)); remoteSet.add(this.buildLocalWatchProgressKey(type, row.content_id, episodeId));

View file

@ -17,6 +17,11 @@ if (!TRAKT_CLIENT_ID || !TRAKT_CLIENT_SECRET) {
logger.warn('[TraktService] Missing Trakt env vars. Trakt integration will be disabled.'); 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 // Types
export interface TraktUser { export interface TraktUser {
username: string; username: string;
@ -690,9 +695,13 @@ export class TraktService {
const now = Date.now(); const now = Date.now();
let cleanupCount = 0; 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()) { 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); this.lastStopCalls.delete(key);
cleanupCount++; cleanupCount++;
} }
@ -1867,21 +1876,24 @@ export class TraktService {
*/ */
public async startWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> { public async startWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
try { try {
// Validate content data before making API call
const validation = this.validateContentData(contentData); const validation = this.validateContentData(contentData);
if (!validation.isValid) { 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; return null;
} }
const payload = await this.buildScrobblePayload(contentData, progress); const payload = await this.buildScrobblePayload(contentData, progress);
if (!payload) { if (!payload) {
console.log('[TraktService] /scrobble/start payload is null');
return 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) { } catch (error) {
logger.error('[TraktService] Failed to start watching:', error); console.log('[TraktService] /scrobble/start ERROR:', error);
return null; return null;
} }
} }
@ -1923,21 +1935,24 @@ export class TraktService {
*/ */
public async stopWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> { public async stopWatching(contentData: TraktContentData, progress: number): Promise<TraktScrobbleResponse | null> {
try { try {
// Validate content data before making API call
const validation = this.validateContentData(contentData); const validation = this.validateContentData(contentData);
if (!validation.isValid) { 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; return null;
} }
const payload = await this.buildScrobblePayload(contentData, progress); const payload = await this.buildScrobblePayload(contentData, progress);
if (!payload) { if (!payload) {
console.log('[TraktService] /scrobble/stop payload is null');
return 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) { } catch (error) {
logger.error('[TraktService] Failed to stop watching:', error); console.log('[TraktService] /scrobble/stop ERROR:', error);
return null; return null;
} }
} }
@ -2235,31 +2250,28 @@ export class TraktService {
public async scrobbleStart(contentData: TraktContentData, progress: number): Promise<boolean> { public async scrobbleStart(contentData: TraktContentData, progress: number): Promise<boolean> {
try { try {
if (!await this.isAuthenticated()) { if (!await this.isAuthenticated()) {
console.log('[TraktService] scrobbleStart: not authenticated');
return false; return false;
} }
const watchingKey = this.getWatchingKey(contentData); 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)) { if (this.isRecentlyScrobbled(contentData)) {
logger.log(`[TraktService] Content was recently scrobbled, skipping start: ${contentData.title}`); console.log(`[TraktService] scrobbleStart BLOCKED: recently scrobbled`);
return true; return true;
} }
// ENHANCED PROTECTION: Check if we recently stopped this content with high progress if (this.scrobbledItems.has(watchingKey)) {
// This prevents restarting sessions for content that was just completed const scrobbledTime = this.scrobbledTimestamps.get(watchingKey);
const lastStopTime = this.lastStopCalls.get(watchingKey); if (scrobbledTime && (Date.now() - scrobbledTime) < 30000) {
if (lastStopTime && (Date.now() - lastStopTime) < 30000) { // 30 seconds console.log(`[TraktService] scrobbleStart BLOCKED: scrobbled ${((Date.now() - scrobbledTime) / 1000).toFixed(1)}s ago`);
logger.log(`[TraktService] Recently stopped this content (${((Date.now() - lastStopTime) / 1000).toFixed(1)}s ago), preventing restart: ${contentData.title}`);
return true; return true;
} }
}
// Debug log removed to reduce terminal noise
// Only start if not already watching this content
if (this.currentlyWatching.has(watchingKey)) { if (this.currentlyWatching.has(watchingKey)) {
logger.log(`[TraktService] Already watching this content, skipping start: ${contentData.title}`); this.currentlyWatching.delete(watchingKey);
return true; // Already started
} }
const result = await this.queueRequest(async () => { const result = await this.queueRequest(async () => {
@ -2268,13 +2280,14 @@ export class TraktService {
if (result) { if (result) {
this.currentlyWatching.add(watchingKey); this.currentlyWatching.add(watchingKey);
logger.log(`[TraktService] Started watching ${contentData.type}: ${contentData.title}`); console.log(`[TraktService] scrobbleStart SUCCESS: ${contentData.title}`);
return true; return true;
} }
console.log(`[TraktService] scrobbleStart FAILED: result was null`);
return false; return false;
} catch (error) { } catch (error) {
logger.error('[TraktService] Failed to start scrobbling:', error); console.log('[TraktService] scrobbleStart ERROR:', error);
return false; 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> { public async scrobbleStop(contentData: TraktContentData, progress: number): Promise<boolean> {
try { try {
@ -2334,52 +2351,48 @@ export class TraktService {
const watchingKey = this.getWatchingKey(contentData); const watchingKey = this.getWatchingKey(contentData);
const now = Date.now(); 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); const lastStopTime = this.lastStopCalls.get(watchingKey);
if (lastStopTime && (now - lastStopTime) < 1000) { if (lastStopTime && (now - lastStopTime) < 1000) {
logger.log(`[TraktService] Ignoring duplicate stop call for ${contentData.title} (last stop ${((now - lastStopTime) / 1000).toFixed(1)}s ago)`); 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); this.lastStopCalls.set(watchingKey, now);
// Use pause if below user threshold, stop only when ready to scrobble // Always use /scrobble/stop — Trakt decides pause vs scrobble based on progress
const useStop = progress >= this.completionThreshold;
const result = await this.queueRequest(async () => { const result = await this.queueRequest(async () => {
return useStop return await this.stopWatching(contentData, progress);
? await this.stopWatching(contentData, progress)
: await this.pauseWatching(contentData, progress);
}); });
if (result) { if (result) {
this.currentlyWatching.delete(watchingKey); this.currentlyWatching.delete(watchingKey);
// Mark as scrobbled if >= user threshold to prevent future duplicates and restarts // Mark as scrobbled if >= 80% to prevent future duplicates
if (progress >= this.completionThreshold) { if (progress >= TRAKT_SCROBBLE_THRESHOLD) {
this.scrobbledItems.add(watchingKey); this.scrobbledItems.add(watchingKey);
this.scrobbledTimestamps.set(watchingKey, Date.now()); 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 >= TRAKT_SCROBBLE_THRESHOLD ? 'scrobbled' : 'paused';
const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused'; logger.log(`[TraktService] /scrobble/stop sent: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
return true; return true;
} else { } else {
// If failed, remove from lastStopCalls so we can try again
this.lastStopCalls.delete(watchingKey); this.lastStopCalls.delete(watchingKey);
} }
return false; return false;
} catch (error) { } catch (error) {
// Handle rate limiting errors more gracefully
if (error instanceof Error && error.message.includes('429')) { if (error instanceof Error && error.message.includes('429')) {
logger.warn('[TraktService] Rate limited, will retry later'); logger.warn('[TraktService] Rate limited, will retry later');
return true; return true;
} }
logger.error('[TraktService] Failed to stop scrobbling:', error); logger.error('[TraktService] Failed to stop scrobbling:', error);
return false; return false;
} }
@ -2425,6 +2438,7 @@ export class TraktService {
/** /**
* Immediate scrobble stop - bypasses queue for instant user feedback * 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> { public async scrobbleStopImmediate(contentData: TraktContentData, progress: number): Promise<boolean> {
try { try {
@ -2434,7 +2448,12 @@ export class TraktService {
const watchingKey = this.getWatchingKey(contentData); 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); const lastStopTime = this.lastStopCalls.get(watchingKey);
if (lastStopTime && (Date.now() - lastStopTime) < 200) { if (lastStopTime && (Date.now() - lastStopTime) < 200) {
return true; return true;
@ -2442,24 +2461,19 @@ export class TraktService {
this.lastStopCalls.set(watchingKey, Date.now()); this.lastStopCalls.set(watchingKey, Date.now());
// BYPASS QUEUE: Use pause if below user threshold, stop only when ready to scrobble // Always use /scrobble/stop — Trakt decides pause vs scrobble based on progress
const useStop = progress >= this.completionThreshold; const result = await this.stopWatching(contentData, progress);
const result = useStop
? await this.stopWatching(contentData, progress)
: await this.pauseWatching(contentData, progress);
if (result) { if (result) {
this.currentlyWatching.delete(watchingKey); this.currentlyWatching.delete(watchingKey);
// Mark as scrobbled if >= user threshold to prevent future duplicates and restarts if (progress >= TRAKT_SCROBBLE_THRESHOLD) {
if (progress >= this.completionThreshold) {
this.scrobbledItems.add(watchingKey); this.scrobbledItems.add(watchingKey);
this.scrobbledTimestamps.set(watchingKey, Date.now()); this.scrobbledTimestamps.set(watchingKey, Date.now());
} }
// Action reflects actual endpoint used based on user threshold const action = progress >= TRAKT_SCROBBLE_THRESHOLD ? 'scrobbled' : 'paused';
const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused'; logger.log(`[TraktService] IMMEDIATE /scrobble/stop: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
logger.log(`[TraktService] IMMEDIATE: Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
return true; return true;
} }
@ -3246,14 +3260,12 @@ export class TraktService {
*/ */
private handleAppStateChange = (nextState: AppStateStatus) => { private handleAppStateChange = (nextState: AppStateStatus) => {
if (nextState !== 'active') { if (nextState !== 'active') {
// Clear tracking maps to reduce memory pressure when app goes to background // Only clear the request queue to prevent background processing.
this.scrobbledItems.clear(); // DO NOT clear scrobbledItems / currentlyWatching / lastStopCalls here.
this.scrobbledTimestamps.clear(); // Clearing them causes duplicate scrobble entries when the app backgrounds
this.currentlyWatching.clear(); // during a long pause and then resumes — all dedup guards are gone and
this.lastSyncTimes.clear(); // scrobbleStart fires a fresh /scrobble/start for the same content.
this.lastStopCalls.clear(); // These maps are small and already expire via cleanupOldStopCalls().
// Clear request queue to prevent background processing
this.requestQueue = []; this.requestQueue = [];
this.isProcessingQueue = false; this.isProcessingQueue = false;
} }

View file

@ -3,7 +3,7 @@ class Logger {
constructor() { constructor() {
// __DEV__ is a global variable in React Native // __DEV__ is a global variable in React Native
this.isEnabled = false; this.isEnabled = __DEV__;
} }
log(...args: any[]) { log(...args: any[]) {