mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-24 18:13:09 +00:00
Merge pull request #685 from chrisk325/patch-28
fix: duplicate trakt entries on pause and play + fix streams fetching logic for unusual "types"
This commit is contained in:
commit
d4977c2fb0
4 changed files with 95 additions and 25 deletions
|
|
@ -210,6 +210,11 @@ export const useWatchProgress = (
|
||||||
wasPausedRef.current = paused;
|
wasPausedRef.current = paused;
|
||||||
if (becamePaused) {
|
if (becamePaused) {
|
||||||
void saveWatchProgress();
|
void saveWatchProgress();
|
||||||
|
} else {
|
||||||
|
// Became unpaused — open/re-open the Trakt scrobble session
|
||||||
|
if (durationRef.current > 0) {
|
||||||
|
void traktAutosyncRef.current.handlePlaybackStart(currentTimeRef.current, durationRef.current);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,7 +243,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);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,8 @@ 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.
|
||||||
|
let effectiveStreamType: string = resolvedTypeRef.current || normalizedType;
|
||||||
|
|
||||||
if (id.startsWith('tmdb:')) {
|
if (id.startsWith('tmdb:')) {
|
||||||
tmdbId = id.split(':')[1];
|
tmdbId = id.split(':')[1];
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,23 @@ interface TraktAutosyncOptions {
|
||||||
episodeId?: string;
|
episodeId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module-level map: contentKey → { stoppedAt, progress, isComplete }
|
||||||
|
// Survives component unmount/remount so re-mounting the player for the same
|
||||||
|
// content (e.g. app background → resume) doesn't fire a duplicate scrobble/start.
|
||||||
|
const recentlyStoppedSessions = new Map<string, {
|
||||||
|
stoppedAt: number;
|
||||||
|
progress: number;
|
||||||
|
isComplete: boolean;
|
||||||
|
}>();
|
||||||
|
const SESSION_RESUME_WINDOW_MS = 20 * 60 * 1000; // 20 minutes
|
||||||
|
|
||||||
|
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,
|
||||||
|
|
@ -53,24 +70,39 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
|
|
||||||
// Generate a unique session key for this content instance
|
// Generate a unique session key for this content instance
|
||||||
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 we're re-mounting for the same content within the resume window.
|
||||||
hasStartedWatching.current = false;
|
// If so, restore the stopped/complete state so we don't fire a duplicate start.
|
||||||
hasStopped.current = false;
|
const prior = recentlyStoppedSessions.get(contentKey);
|
||||||
isSessionComplete.current = false;
|
const now = Date.now();
|
||||||
isUnmounted.current = false; // Reset unmount flag for new mount
|
if (prior && (now - prior.stoppedAt) < SESSION_RESUME_WINDOW_MS) {
|
||||||
lastStopCall.current = 0;
|
hasStartedWatching.current = false; // will re-start cleanly if needed
|
||||||
|
hasStopped.current = prior.isComplete ? true : prior.progress > 0; // block restart if already stopped
|
||||||
logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
|
isSessionComplete.current = prior.isComplete;
|
||||||
|
lastSyncProgress.current = prior.progress;
|
||||||
|
lastStopCall.current = prior.stoppedAt;
|
||||||
|
logger.log(`[TraktAutosync] Remount detected for same content within ${SESSION_RESUME_WINDOW_MS / 1000}s window. Restoring: hasStopped=${hasStopped.current}, isComplete=${isSessionComplete.current}, progress=${prior.progress.toFixed(1)}%`);
|
||||||
|
} else {
|
||||||
|
// Genuinely new content or window expired — reset everything
|
||||||
|
hasStartedWatching.current = false;
|
||||||
|
hasStopped.current = false;
|
||||||
|
isSessionComplete.current = false;
|
||||||
|
lastStopCall.current = 0;
|
||||||
|
lastSyncProgress.current = 0;
|
||||||
|
lastSyncTime.current = 0;
|
||||||
|
if (prior) {
|
||||||
|
recentlyStoppedSessions.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]);
|
||||||
|
|
@ -277,6 +309,14 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip if session was already stopped (e.g. after background/pause).
|
||||||
|
// Without this, the fallback "force start" block inside handleProgressUpdate
|
||||||
|
// would fire a new /scrobble/start on the first periodic save after a remount,
|
||||||
|
// bypassing the hasStopped guard in handlePlaybackStart entirely.
|
||||||
|
if (hasStopped.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
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));
|
||||||
|
|
@ -511,6 +551,15 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
lastStopCall.current = now;
|
lastStopCall.current = now;
|
||||||
hasStopped.current = true;
|
hasStopped.current = true;
|
||||||
|
|
||||||
|
// Persist to module-level map so a remount for the same content within the
|
||||||
|
// resume window won't fire a duplicate scrobble/start.
|
||||||
|
const contentKey = getContentKey(options);
|
||||||
|
recentlyStoppedSessions.set(contentKey, {
|
||||||
|
stoppedAt: now,
|
||||||
|
progress: progressPercent,
|
||||||
|
isComplete: false // updated below if scrobble succeeds at threshold
|
||||||
|
});
|
||||||
|
|
||||||
const contentData = buildContentData();
|
const contentData = buildContentData();
|
||||||
|
|
||||||
// Skip if content data is invalid
|
// Skip if content data is invalid
|
||||||
|
|
@ -549,6 +598,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
} else if (shouldSyncTrakt) {
|
} else if (shouldSyncTrakt) {
|
||||||
// If Trakt stop failed, reset the stop flag so we can try again later
|
// If Trakt stop failed, reset the stop flag so we can try again later
|
||||||
hasStopped.current = false;
|
hasStopped.current = false;
|
||||||
|
recentlyStoppedSessions.delete(getContentKey(options));
|
||||||
logger.warn(`[TraktAutosync] Trakt: Failed to stop watching, reset stop flag for retry`);
|
logger.warn(`[TraktAutosync] Trakt: Failed to stop watching, reset stop flag for retry`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -573,6 +623,12 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
// Mark session as complete if >= user completion threshold
|
// Mark session as complete if >= user completion threshold
|
||||||
if (progressPercent >= autosyncSettings.completionThreshold) {
|
if (progressPercent >= autosyncSettings.completionThreshold) {
|
||||||
isSessionComplete.current = true;
|
isSessionComplete.current = true;
|
||||||
|
// Update module-level map to reflect completion so a remount won't restart
|
||||||
|
const ck = getContentKey(options);
|
||||||
|
const existing = recentlyStoppedSessions.get(ck);
|
||||||
|
if (existing) {
|
||||||
|
recentlyStoppedSessions.set(ck, { ...existing, isComplete: true, progress: progressPercent });
|
||||||
|
}
|
||||||
logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`);
|
logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`);
|
||||||
|
|
||||||
// Ensure local watch progress reflects completion so UI shows as watched
|
// Ensure local watch progress reflects completion so UI shows as watched
|
||||||
|
|
@ -633,6 +689,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
unmountCount.current = 0;
|
unmountCount.current = 0;
|
||||||
sessionKey.current = null;
|
sessionKey.current = null;
|
||||||
lastStopCall.current = 0;
|
lastStopCall.current = 0;
|
||||||
|
recentlyStoppedSessions.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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -690,9 +690,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++;
|
||||||
}
|
}
|
||||||
|
|
@ -3246,14 +3250,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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue