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