diff --git a/package-lock.json b/package-lock.json index 72a9b01..ceb2b94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "expo-haptics": "~14.0.1", "expo-image": "~2.0.7", "expo-intent-launcher": "~12.0.2", - "expo-libvlc-player": "^2.1.7", + "expo-libvlc-player": "^2.2.1", "expo-linear-gradient": "~14.0.2", "expo-localization": "~16.0.1", "expo-notifications": "~0.29.14", diff --git a/package.json b/package.json index 47e1c4f..0f5dd7d 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "expo-haptics": "~14.0.1", "expo-image": "~2.0.7", "expo-intent-launcher": "~12.0.2", - "expo-libvlc-player": "^2.1.7", + "expo-libvlc-player": "^2.2.1", "expo-linear-gradient": "~14.0.2", "expo-localization": "~16.0.1", "expo-notifications": "~0.29.14", diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 798af7e..9bb9941 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -643,21 +643,24 @@ const WatchProgressDisplay = memo(({ {/* Enhanced text container with better typography */} - {progressData.displayText} - - - - - {progressData.episodeInfo} - + + + + {/* Only show episode info for series */} + {progressData.episodeInfo && ( + + {progressData.episodeInfo} + + )} {/* Trakt sync status with enhanced styling */} {progressData.syncStatus && ( diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index 225ee17..5a6edea 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -134,7 +134,9 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } try { - const progressPercent = (currentTime / duration) * 100; + // Clamp progress between 0 and 100 + const rawProgress = (currentTime / duration) * 100; + const progressPercent = Math.min(100, Math.max(0, rawProgress)); const contentData = buildContentData(); const success = await startWatching(contentData, progressPercent); @@ -164,7 +166,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } try { - const progressPercent = (currentTime / duration) * 100; + 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) @@ -280,6 +283,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { try { let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; + // Clamp progress between 0 and 100 + progressPercent = Math.min(100, Math.max(0, progressPercent)); // Initial progress calculation logging removed // For unmount calls, always use the highest available progress @@ -301,7 +306,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { ); if (savedProgress && savedProgress.duration > 0) { - const savedProgressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; + const savedProgressPercent = Math.min(100, Math.max(0, (savedProgress.currentTime / savedProgress.duration) * 100)); if (savedProgressPercent > maxProgress) { maxProgress = savedProgressPercent; } @@ -334,10 +339,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } - // For natural end events, always set progress to at least 90% - if (reason === 'ended' && progressPercent < 90) { - logger.log(`[TraktAutosync] Natural end detected but progress is low (${progressPercent.toFixed(1)}%), boosting to 90%`); - progressPercent = 90; + // For natural end events, ensure we cross Trakt's 80% scrobble threshold reliably. + // If close to the end, boost to 95% to avoid rounding issues. + if (reason === 'ended' && progressPercent < 95) { + logger.log(`[TraktAutosync] Natural end detected at ${progressPercent.toFixed(1)}%, boosting to 95% for scrobble`); + progressPercent = 95; } // Mark stop attempt and update timestamp @@ -366,6 +372,25 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { if (progressPercent >= 80) { isSessionComplete.current = true; logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`); + + // Ensure local watch progress reflects completion so UI shows as watched + try { + if (duration > 0) { + await storageService.setWatchProgress( + options.id, + options.type, + { + currentTime: duration, + duration, + lastUpdated: Date.now(), + traktSynced: true, + traktProgress: Math.max(progressPercent, 100), + } as any, + options.episodeId, + { forceNotify: true } + ); + } + } catch {} } logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); diff --git a/src/services/hybridCacheService.ts b/src/services/hybridCacheService.ts index cbd4470..a7045a9 100644 --- a/src/services/hybridCacheService.ts +++ b/src/services/hybridCacheService.ts @@ -132,7 +132,7 @@ class HybridCacheService { } /** - * Get list of scrapers that need to be re-run + * Get list of scrapers that need to be re-run (expired, failed, or not cached) */ async getScrapersToRerun( type: string, @@ -147,14 +147,26 @@ class HybridCacheService { const validScraperIds = new Set(validResults.map(r => r.scraperId)); const expiredScraperIds = new Set(expiredScrapers); - // Return scrapers that are either expired or not cached + // Get scrapers that previously failed (returned no streams) + const failedScraperIds = new Set( + validResults + .filter(r => !r.success || r.streams.length === 0) + .map(r => r.scraperId) + ); + + // Return scrapers that are: + // 1. Not cached at all + // 2. Expired + // 3. Previously failed (regardless of cache status) const scrapersToRerun = availableScrapers .filter(scraper => - !validScraperIds.has(scraper.id) || expiredScraperIds.has(scraper.id) + !validScraperIds.has(scraper.id) || + expiredScraperIds.has(scraper.id) || + failedScraperIds.has(scraper.id) ) .map(scraper => scraper.id); - logger.log(`[HybridCache] Scrapers to re-run: ${scrapersToRerun.join(', ')}`); + logger.log(`[HybridCache] Scrapers to re-run: ${scrapersToRerun.join(', ')} (not cached: ${availableScrapers.filter(s => !validScraperIds.has(s.id)).length}, expired: ${expiredScrapers.length}, failed: ${failedScraperIds.size})`); return scrapersToRerun; } diff --git a/src/services/localScraperCacheService.ts b/src/services/localScraperCacheService.ts index 67ccf1f..e00eb6e 100644 --- a/src/services/localScraperCacheService.ts +++ b/src/services/localScraperCacheService.ts @@ -215,7 +215,7 @@ class LocalScraperCacheService { } /** - * Get list of scrapers that need to be re-run (expired or failed) + * Get list of scrapers that need to be re-run (expired, failed, or not cached) */ async getScrapersToRerun( type: string, @@ -229,14 +229,26 @@ class LocalScraperCacheService { const validScraperIds = new Set(validResults.map(r => r.scraperId)); const expiredScraperIds = new Set(expiredScrapers); - // Return scrapers that are either expired or not cached at all + // Get scrapers that previously failed (returned no streams) + const failedScraperIds = new Set( + validResults + .filter(r => !r.success || r.streams.length === 0) + .map(r => r.scraperId) + ); + + // Return scrapers that are: + // 1. Not cached at all + // 2. Expired + // 3. Previously failed (regardless of cache status) const scrapersToRerun = availableScrapers .filter(scraper => - !validScraperIds.has(scraper.id) || expiredScraperIds.has(scraper.id) + !validScraperIds.has(scraper.id) || + expiredScraperIds.has(scraper.id) || + failedScraperIds.has(scraper.id) ) .map(scraper => scraper.id); - logger.log(`[LocalScraperCache] Scrapers to re-run: ${scrapersToRerun.join(', ')}`); + logger.log(`[LocalScraperCache] Scrapers to re-run: ${scrapersToRerun.join(', ')} (not cached: ${availableScrapers.filter(s => !validScraperIds.has(s.id)).length}, expired: ${expiredScrapers.length}, failed: ${failedScraperIds.size})`); return scrapersToRerun; } diff --git a/src/services/localScraperService.ts b/src/services/localScraperService.ts index 0537cd6..e8f1467 100644 --- a/src/services/localScraperService.ts +++ b/src/services/localScraperService.ts @@ -905,16 +905,26 @@ class LocalScraperService { } // Determine which scrapers need to be re-run - const scrapersToRerun = enabledScrapers.filter(scraper => - expiredScrapers.includes(scraper.id) || !validResults.some(r => r.scraperId === scraper.id) - ); + const scrapersToRerun = enabledScrapers.filter(scraper => { + const hasValidResult = validResults.some(r => r.scraperId === scraper.id); + const isExpired = expiredScrapers.includes(scraper.id); + const hasFailedResult = validResults.some(r => r.scraperId === scraper.id && (!r.success || r.streams.length === 0)); + + return !hasValidResult || isExpired || hasFailedResult; + }); if (scrapersToRerun.length === 0) { logger.log('[LocalScraperService] All scrapers have valid cached results'); return; } - logger.log(`[LocalScraperService] Re-running ${scrapersToRerun.length} scrapers (${expiredScrapers.length} expired, ${scrapersToRerun.length - expiredScrapers.length} not cached) for ${type}:${tmdbId}`); + logger.log(`[LocalScraperService] Re-running ${scrapersToRerun.length} scrapers for ${type}:${tmdbId}`, { + totalEnabled: enabledScrapers.length, + expired: expiredScrapers.length, + failed: validResults.filter(r => !r.success || r.streams.length === 0).length, + notCached: enabledScrapers.length - validResults.length, + scrapersToRerun: scrapersToRerun.map(s => s.name) + }); // Generate a lightweight request id for tracing const requestId = `rs_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index a068d1b..b8a7835 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -188,19 +188,56 @@ class StremioService { this.initializationPromise = this.initialize(); } - // Shared validator for content IDs eligible for metadata requests + // Dynamic validator for content IDs based on installed addon capabilities public isValidContentId(type: string, id: string | null | undefined): boolean { const isValidType = type === 'movie' || type === 'series'; const lowerId = (id || '').toLowerCase(); - const looksLikeImdb = /^tt\d+/.test(lowerId); - const looksLikeKitsu = lowerId.startsWith('kitsu:') || lowerId === 'kitsu'; - const looksLikeSeriesId = lowerId.startsWith('series:'); const isNullishId = !id || lowerId === 'null' || lowerId === 'undefined'; const providerLikeIds = new Set(['moviebox', 'torbox']); const isProviderSlug = providerLikeIds.has(lowerId); if (!isValidType || isNullishId || isProviderSlug) return false; - return looksLikeImdb || looksLikeKitsu || looksLikeSeriesId; + + // Get all supported ID prefixes from installed addons + const supportedPrefixes = this.getAllSupportedIdPrefixes(type); + + // Check if the ID matches any supported prefix + return supportedPrefixes.some(prefix => lowerId.startsWith(prefix.toLowerCase())); + } + + // Get all ID prefixes supported by installed addons for a given content type + public getAllSupportedIdPrefixes(type: string): string[] { + const addons = this.getInstalledAddons(); + const prefixes = new Set(); + + for (const addon of addons) { + // Check addon-level idPrefixes + if (addon.idPrefixes && Array.isArray(addon.idPrefixes)) { + addon.idPrefixes.forEach(prefix => prefixes.add(prefix)); + } + + // Check resource-level idPrefixes + if (addon.resources && Array.isArray(addon.resources)) { + for (const resource of addon.resources) { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as ResourceObject; + // Only include prefixes for resources that support the content type + if (Array.isArray(typedResource.types) && typedResource.types.includes(type)) { + if (Array.isArray(typedResource.idPrefixes)) { + typedResource.idPrefixes.forEach(prefix => prefixes.add(prefix)); + } + } + } + } + } + } + + // Always include common prefixes as fallback + prefixes.add('tt'); // IMDb + prefixes.add('kitsu:'); // Kitsu + prefixes.add('series:'); // Series + + return Array.from(prefixes); } static getInstance(): StremioService { @@ -723,13 +760,16 @@ class StremioService { async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise { try { + // Validate content ID first + if (!this.isValidContentId(type, id)) { + return null; + } + const addons = this.getInstalledAddons(); // If a preferred addon is specified, try it first if (preferredAddonId) { - logger.log(`🔍 [getMetaDetails] Looking for preferred addon: ${preferredAddonId}`); const preferredAddon = addons.find(addon => addon.id === preferredAddonId); - logger.log(`🔍 [getMetaDetails] Found preferred addon: ${preferredAddon ? preferredAddon.id : 'null'}`); if (preferredAddon && preferredAddon.resources) { // Build URL for metadata request @@ -739,6 +779,7 @@ class StremioService { // Check if addon supports meta resource for this type let hasMetaSupport = false; + let supportsIdPrefix = false; for (const resource of preferredAddon.resources) { // Check if the current element is a ResourceObject @@ -748,6 +789,12 @@ class StremioService { Array.isArray(typedResource.types) && typedResource.types.includes(type)) { hasMetaSupport = true; + // Check idPrefix support + if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { + supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p)); + } else { + supportsIdPrefix = true; + } break; } } @@ -755,17 +802,19 @@ class StremioService { else if (typeof resource === 'string' && resource === 'meta' && preferredAddon.types) { if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) { hasMetaSupport = true; + // Check addon-level idPrefixes + if (preferredAddon.idPrefixes && Array.isArray(preferredAddon.idPrefixes) && preferredAddon.idPrefixes.length > 0) { + supportsIdPrefix = preferredAddon.idPrefixes.some(p => id.startsWith(p)); + } else { + supportsIdPrefix = true; + } break; } } } - logger.log(`🔍 Meta support check: ${hasMetaSupport} (addon types: ${JSON.stringify(preferredAddon.types)})`); - - if (hasMetaSupport) { + if (hasMetaSupport && supportsIdPrefix) { try { - logger.log(`🔗 [${preferredAddon.name}] Requesting metadata: ${url} (preferred, id=${id}, type=${type})`); - const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); @@ -773,7 +822,7 @@ class StremioService { if (response.data && response.data.meta) { return response.data.meta; } - } catch (error) { + } catch (error: any) { // Continue trying other addons } } @@ -785,7 +834,7 @@ class StremioService { 'https://v3-cinemeta.strem.io', 'http://v3-cinemeta.strem.io' ]; - + for (const baseUrl of cinemetaUrls) { try { const encodedId = encodeURIComponent(id); @@ -804,7 +853,6 @@ class StremioService { } // If Cinemeta fails, try other addons (excluding the preferred one already tried) - for (const addon of addons) { if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue; @@ -843,6 +891,7 @@ class StremioService { } } } + // Require both meta support and idPrefix compatibility if (!(hasMetaSupport && supportsIdPrefix)) continue; @@ -851,7 +900,6 @@ class StremioService { const encodedId = encodeURIComponent(id); const url = queryParams ? `${baseUrl}/meta/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/meta/${type}/${encodedId}.json`; - logger.log(`🔗 [${addon.name}] Requesting metadata: ${url} (id=${id}, type=${type})`); const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); @@ -860,15 +908,10 @@ class StremioService { return response.data.meta; } } catch (error) { - logger.warn(`❌ Failed to fetch meta from ${addon.name} (${addon.id}):`, error); continue; // Try next addon } } - // Only log this warning in debug mode to reduce noise - if (__DEV__) { - logger.warn('No metadata found from any addon'); - } return null; } catch (error) { logger.error('Error in getMetaDetails:', error); @@ -1476,6 +1519,7 @@ class StremioService { return false; } + } export const stremioService = StremioService.getInstance(); diff --git a/src/services/traktService.ts b/src/services/traktService.ts index c5a1113..d3bf759 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -1547,6 +1547,9 @@ export class TraktService { */ private async buildScrobblePayload(contentData: TraktContentData, progress: number): Promise { try { + // Clamp progress between 0 and 100 and round to 2 decimals for API + const clampedProgress = Math.min(100, Math.max(0, Math.round(progress * 100) / 100)); + // Enhanced debug logging for payload building logger.log('[TraktService] Building scrobble payload:', { type: contentData.type, @@ -1558,7 +1561,7 @@ export class TraktService { showTitle: contentData.showTitle, showYear: contentData.showYear, showImdbId: contentData.showImdbId, - progress: progress + progress: clampedProgress }); if (contentData.type === 'movie') { @@ -1583,7 +1586,7 @@ export class TraktService { imdb: imdbIdWithPrefix } }, - progress: Math.round(progress * 100) / 100 // Round to 2 decimal places + progress: clampedProgress }; logger.log('[TraktService] Movie payload built:', payload); @@ -1609,7 +1612,7 @@ export class TraktService { season: contentData.season, number: contentData.episode }, - progress: Math.round(progress * 100) / 100 + progress: clampedProgress }; // Add show IMDB ID if available