Merge branch 'tapframe:main' into fix-timeline-time

This commit is contained in:
Christoffer Kronblad 2025-10-04 16:22:21 +02:00 committed by GitHub
commit f72404e22a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 3212 additions and 463 deletions

1
.gitignore vendored
View file

@ -56,3 +56,4 @@ src/screens/xavio.md
/KSPlayer
/exobase
ffmpegreadme.md
toast.md

119
package-lock.json generated
View file

@ -14,7 +14,7 @@
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "~14.0.4",
"@lottiefiles/dotlottie-react": "^0.6.5",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-community/slider": "4.5.5",
@ -28,7 +28,7 @@
"@supabase/supabase-js": "^2.54.0",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.11.0",
"axios": "^1.12.2",
"axios-cookiejar-support": "^6.0.4",
"cheerio-without-node-native": "^0.20.2",
"crypto-js": "^4.2.0",
@ -72,9 +72,11 @@
"react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-vector-icons": "^10.3.0",
"react-native-video": "^6.12.0",
"react-native-web": "~0.19.13",
"react-native-wheel-color-picker": "^1.3.1"
"react-native-wheel-color-picker": "^1.3.1",
"toastify-react-native": "^7.2.3"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@ -12965,6 +12967,93 @@
"react-native": "*"
}
},
"node_modules/react-native-vector-icons": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz",
"integrity": "sha512-IFQ0RE57819hOUdFvgK4FowM5aMXg7C7XKsuGLevqXkkIJatc3QopN0wYrb2IrzUgmdpfP+QVIbI3S6h7M0btw==",
"deprecated": "react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2",
"yargs": "^16.1.1"
},
"bin": {
"fa-upgrade.sh": "bin/fa-upgrade.sh",
"fa5-upgrade": "bin/fa5-upgrade.sh",
"fa6-upgrade": "bin/fa6-upgrade.sh",
"generate-icon": "bin/generate-icon.js"
}
},
"node_modules/react-native-vector-icons/node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^7.0.0"
}
},
"node_modules/react-native-vector-icons/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/react-native-vector-icons/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/react-native-vector-icons/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/react-native-vector-icons/node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"license": "MIT",
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.0",
"y18n": "^5.0.5",
"yargs-parser": "^20.2.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/react-native-vector-icons/node_modules/yargs-parser": {
"version": "20.2.9",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/react-native-video": {
"version": "6.16.1",
"resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-6.16.1.tgz",
@ -14853,6 +14942,19 @@
"node": ">=8.0"
}
},
"node_modules/toastify-react-native": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/toastify-react-native/-/toastify-react-native-7.2.3.tgz",
"integrity": "sha512-ngmpTKlTo0IRddwSsNWK+YKbB2veqotHy7Zpil4eksoLAlq0RPSgdVOk5QDEDUONJQ4r7ljGYeRW68KBztirsg==",
"license": "MIT",
"dependencies": {
"react-native-vector-icons": "*"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@ -15007,17 +15109,6 @@
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
"license": "MIT"
},
"node_modules/undici": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
"integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",

View file

@ -14,7 +14,7 @@
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "~14.0.4",
"@lottiefiles/dotlottie-react": "^0.6.5",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-community/slider": "4.5.5",
@ -28,7 +28,7 @@
"@supabase/supabase-js": "^2.54.0",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.11.0",
"axios": "^1.12.2",
"axios-cookiejar-support": "^6.0.4",
"cheerio-without-node-native": "^0.20.2",
"crypto-js": "^4.2.0",
@ -72,9 +72,11 @@
"react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-vector-icons": "^10.3.0",
"react-native-video": "^6.12.0",
"react-native-web": "~0.19.13",
"react-native-wheel-color-picker": "^1.3.1"
"react-native-wheel-color-picker": "^1.3.1",
"toastify-react-native": "^7.2.3"
},
"devDependencies": {
"@babel/core": "^7.25.2",

View file

@ -1,11 +1,16 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Animated } from 'react-native';
import { Toast } from 'toastify-react-native';
import { DeviceEventEmitter } from 'react-native';
import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Animated, Share } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { catalogService, StreamingContent } from '../../services/catalogService';
import { DropUpMenu } from './DropUpMenu';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { storageService } from '../../services/storageService';
import { TraktService } from '../../services/traktService';
interface ContentItemProps {
item: StreamingContent;
@ -70,6 +75,17 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
});
return () => unsubscribe();
}, [item.id, item.type]);
// Load watched state from AsyncStorage when item changes
useEffect(() => {
const updateWatched = () => {
AsyncStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setIsWatched(val === 'true'));
};
updateWatched();
const sub = DeviceEventEmitter.addListener('watchedStatusChanged', updateWatched);
return () => sub.remove();
}, [item.id, item.type]);
const [menuVisible, setMenuVisible] = useState(false);
const [isWatched, setIsWatched] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
@ -102,24 +118,68 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
onPress(item.id, item.type);
}, [item.id, item.type, onPress]);
const handleOptionSelect = useCallback((option: string) => {
const handleOptionSelect = useCallback(async (option: string) => {
switch (option) {
case 'library':
if (inLibrary) {
catalogService.removeFromLibrary(item.type, item.id);
Toast.info('Removed from Library');
} else {
catalogService.addToLibrary(item);
Toast.success('Added to Library');
}
break;
case 'watched':
setIsWatched(prev => !prev);
case 'watched': {
const targetWatched = !isWatched;
setIsWatched(targetWatched);
try {
await AsyncStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
} catch {}
Toast.info(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched');
setTimeout(() => {
DeviceEventEmitter.emit('watchedStatusChanged');
}, 100);
// Best-effort sync: record local progress and push to Trakt if available
if (targetWatched) {
try {
await storageService.setWatchProgress(
item.id,
item.type,
{ currentTime: 1, duration: 1, lastUpdated: Date.now() },
undefined,
{ forceNotify: true, forceWrite: true }
);
} catch {}
if (item.type === 'movie') {
try {
const trakt = TraktService.getInstance();
if (await trakt.isAuthenticated()) {
await trakt.addToWatchedMovies(item.id);
try {
await storageService.updateTraktSyncStatus(item.id, item.type, true, 100);
} catch {}
}
} catch {}
}
}
setMenuVisible(false);
break;
}
case 'playlist':
break;
case 'share':
case 'share': {
let url = '';
if (item.id) {
url = `https://www.imdb.com/title/${item.id}/`;
}
const message = `${item.name}\n${url}`;
Share.share({ message, url, title: item.name });
break;
}
}
}, [item, inLibrary]);
}, [item, inLibrary, isWatched]);
const handleMenuClose = useCallback(() => {
setMenuVisible(false);

View file

@ -92,9 +92,9 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
// Robustly determine if the item is in the library (saved)
const isSaved = typeof isSavedProp === 'boolean' ? isSavedProp : !!item.inLibrary;
const isWatched = !!isWatchedProp;
const menuOptions = [
let menuOptions = [
{
icon: isSaved ? 'bookmark' : 'bookmark-border',
icon: 'bookmark',
label: isSaved ? 'Remove from Library' : 'Add to Library',
action: 'library'
},
@ -103,11 +103,13 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
label: isWatched ? 'Mark as Unwatched' : 'Mark as Watched',
action: 'watched'
},
/*
{
icon: 'playlist-add',
label: 'Add to Playlist',
action: 'playlist'
},
*/
{
icon: 'share',
label: 'Share',
@ -115,6 +117,11 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
}
];
// If used in LibraryScreen, only show 'Remove from Library' if item is in library
if (isSavedProp === true) {
menuOptions = menuOptions.filter(opt => opt.action !== 'library' || isSaved);
}
const backgroundColor = isDarkMode ? '#1A1A1A' : '#FFFFFF';
return (

View file

@ -183,13 +183,32 @@ const KSPlayerCore: React.FC = () => {
try {
// Validate URL first
const urlObj = new URL(url);
// Only decode if the URL appears to be double-encoded
// Check if URL contains encoded characters that shouldn't be there
const hasDoubleEncoding = url.includes('%25') ||
(url.includes('%2F') && url.includes('//')) ||
(url.includes('%3A') && url.includes('://'));
// Be more conservative - only check for clear double-encoding indicators
// Check 1: %25 indicates double-encoded % character
const hasDoubleEncodedPercent = url.includes('%25');
// Check 2: Only flag %2F + // if encoded slashes appear in the path/domain part
// (not just in query params where they might be legitimate base64/etc)
const hasProblematicEncodedSlashes = (() => {
const beforeQuery = url.split('?')[0]; // Get URL before query params
return beforeQuery.includes('%2F') && beforeQuery.includes('//');
})();
// Check 3: Only flag %3A + :// if colons are encoded in the scheme
const hasProblematicEncodedColons = (() => {
const schemeEnd = url.indexOf('://');
if (schemeEnd === -1) return false;
const schemePart = url.substring(0, schemeEnd);
return schemePart.includes('%3A');
})();
const hasDoubleEncoding = hasDoubleEncodedPercent ||
hasProblematicEncodedSlashes ||
hasProblematicEncodedColons;
if (hasDoubleEncoding) {
logger.log('[VideoPlayer] Detected double-encoded URL, decoding once');
return decodeURIComponent(url);

View file

@ -107,10 +107,32 @@ interface UseMetadataReturn {
imdbId: string | null;
scraperStatuses: ScraperStatus[];
activeFetchingScrapers: string[];
clearScraperCache: () => Promise<void>;
invalidateScraperCache: (scraperId: string) => Promise<void>;
invalidateContentCache: (type: string, tmdbId: string, season?: number, episode?: number) => Promise<void>;
getScraperCacheStats: () => Promise<{
local: {
totalEntries: number;
totalSize: number;
oldestEntry: number | null;
newestEntry: number | null;
};
global: {
totalEntries: number;
totalSize: number;
oldestEntry: number | null;
newestEntry: number | null;
hitRate: number;
};
combined: {
totalEntries: number;
hitRate: number;
};
}>;
}
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
const { settings } = useSettings();
const { settings, isLoaded: settingsLoaded } = useSettings();
const [metadata, setMetadata] = useState<StreamingContent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -421,7 +443,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// For TMDB IDs, we need to handle metadata differently
if (type === 'movie') {
if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId);
const movieDetails = await tmdbService.getMovieDetails(tmdbId);
const movieDetails = await tmdbService.getMovieDetails(
tmdbId,
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
);
if (movieDetails) {
const imdbId = movieDetails.imdb_id || movieDetails.external_ids?.imdb_id;
if (imdbId) {
@ -485,7 +510,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Handle TV shows with TMDB IDs
if (__DEV__) logger.log('Fetching TV show details from TMDB for:', tmdbId);
try {
const showDetails = await tmdbService.getTVShowDetails(parseInt(tmdbId));
const showDetails = await tmdbService.getTVShowDetails(
parseInt(tmdbId),
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
);
if (showDetails) {
// Get external IDs to check for IMDb ID
const externalIds = await tmdbService.getShowExternalIds(parseInt(tmdbId));
@ -587,16 +615,52 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (content.status === 'fulfilled' && content.value) {
if (__DEV__) logger.log('[loadMetadata] addon metadata:success', { id: content.value?.id, type: content.value?.type, name: content.value?.name });
setMetadata(content.value);
// Check if item is in library
// Start with addon metadata
let finalMetadata = content.value as StreamingContent;
// If localization is enabled, merge TMDB localized text (name/overview) before first render
try {
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
const tmdbSvc = TMDBService.getInstance();
// Ensure we have a TMDB ID
let finalTmdbId: number | null = tmdbId;
if (!finalTmdbId) {
finalTmdbId = await tmdbSvc.extractTMDBIdFromStremioId(actualId);
if (finalTmdbId) setTmdbId(finalTmdbId);
}
if (finalTmdbId) {
const lang = settings.tmdbLanguagePreference || 'en';
if (type === 'movie') {
const localized = await tmdbSvc.getMovieDetails(String(finalTmdbId), lang);
if (localized) {
finalMetadata = {
...finalMetadata,
name: localized.title || finalMetadata.name,
description: localized.overview || finalMetadata.description,
};
}
} else {
const localized = await tmdbSvc.getTVShowDetails(Number(finalTmdbId), lang);
if (localized) {
finalMetadata = {
...finalMetadata,
name: localized.name || finalMetadata.name,
description: localized.overview || finalMetadata.description,
};
}
}
}
}
} catch (e) {
if (__DEV__) console.log('[useMetadata] failed to merge localized TMDB text', e);
}
// Commit final metadata once and cache it
setMetadata(finalMetadata);
cacheService.setMetadata(id, type, finalMetadata);
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
setInLibrary(isInLib);
cacheService.setMetadata(id, type, content.value);
// Set the final metadata state without fetching logo (this will be handled by MetadataScreen)
setMetadata(content.value);
// Update cache
cacheService.setMetadata(id, type, content.value);
} else {
if (__DEV__) logger.warn('[loadMetadata] addon metadata:not found or failed', { status: content.status, reason: (content as any)?.reason?.message });
throw new Error('Content not found');
@ -693,6 +757,40 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (__DEV__) logger.log('[loadSeriesData] TMDB enrichment disabled; skipping season poster fetch');
}
// If localized TMDB text is enabled, merge episode names/overviews per language
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
try {
const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null);
if (tmdbIdToUse) {
const lang = `${settings.tmdbLanguagePreference || 'en'}-US`;
const seasons = Object.keys(groupedAddonEpisodes).map(Number);
for (const seasonNum of seasons) {
const seasonEps = groupedAddonEpisodes[seasonNum];
// Parallel fetch a reasonable batch (limit concurrency implicitly by season)
const localized = await Promise.all(
seasonEps.map(async ep => {
try {
const data = await tmdbService.getEpisodeDetails(Number(tmdbIdToUse), seasonNum, ep.episode_number, lang);
if (data) {
return {
...ep,
name: data.name || ep.name,
overview: data.overview || ep.overview,
};
}
} catch {}
return ep;
})
);
groupedAddonEpisodes[seasonNum] = localized;
}
if (__DEV__) logger.log('[useMetadata] merged localized episode names/overviews from TMDB');
}
} catch (e) {
if (__DEV__) console.log('[useMetadata] failed to merge localized episode text', e);
}
}
setGroupedEpisodes(groupedAddonEpisodes);
// Determine initial season only once per series
@ -1002,12 +1100,12 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Check completion less frequently to reduce CPU load
const completionInterval = setInterval(checkScrapersCompletion, 2000);
// Fallback timeout after 30 seconds
// Fallback timeout after 1 minute
const fallbackTimeout = setTimeout(() => {
clearInterval(completionInterval);
setLoadingStreams(false);
setActiveFetchingScrapers([]);
}, 30000);
}, 60000);
} catch (error) {
if (__DEV__) console.error('❌ [loadStreams] Failed to load streams:', error);
@ -1178,12 +1276,12 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Check completion less frequently to reduce CPU load
const episodeCompletionInterval = setInterval(checkEpisodeScrapersCompletion, 3000);
// Fallback timeout after 30 seconds
// Fallback timeout after 1 minute
const episodeFallbackTimeout = setTimeout(() => {
clearInterval(episodeCompletionInterval);
setLoadingEpisodeStreams(false);
setActiveFetchingScrapers([]);
}, 30000);
}, 60000);
} catch (error) {
if (__DEV__) console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error);
@ -1242,8 +1340,28 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}, [error, loadAttempts]);
useEffect(() => {
if (!settingsLoaded) return;
// Check for cached streams immediately on mount
const checkAndLoadCachedStreams = async () => {
try {
// This will be handled by the StreamsScreen component
// The useMetadata hook focuses on metadata and episodes
} catch (error) {
if (__DEV__) console.log('[useMetadata] Error checking cached streams on mount:', error);
}
};
loadMetadata();
}, [id, type]);
}, [id, type, settingsLoaded]);
// Re-fetch when localization settings change to guarantee selected language at open
useEffect(() => {
if (!settingsLoaded) return;
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
loadMetadata();
}
}, [settingsLoaded, settings.enrichMetadataWithTMDB, settings.useTmdbLocalizedMetadata, settings.tmdbLanguagePreference]);
// Re-run series data loading when metadata updates with videos
useEffect(() => {
@ -1399,6 +1517,23 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
};
}, [cleanupStreams]);
// Cache management methods
const clearScraperCache = useCallback(async () => {
await localScraperService.clearScraperCache();
}, []);
const invalidateScraperCache = useCallback(async (scraperId: string) => {
await localScraperService.invalidateScraperCache(scraperId);
}, []);
const invalidateContentCache = useCallback(async (type: string, tmdbId: string, season?: number, episode?: number) => {
await localScraperService.invalidateContentCache(type, tmdbId, season, episode);
}, []);
const getScraperCacheStats = useCallback(async () => {
return await localScraperService.getCacheStats();
}, []);
return {
metadata,
loading,
@ -1432,5 +1567,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
imdbId,
scraperStatuses,
activeFetchingScrapers,
clearScraperCache,
invalidateScraperCache,
invalidateContentCache,
getScraperCacheStats,
};
};

View file

@ -78,6 +78,7 @@ export interface AppSettings {
aiChatEnabled: boolean; // Enable/disable Ask AI and AI features
// Metadata enrichment
enrichMetadataWithTMDB: boolean; // Use TMDB to enrich metadata (cast, certification, posters, fallbacks)
useTmdbLocalizedMetadata: boolean; // Use TMDB localized metadata (titles, overviews) per tmdbLanguagePreference
}
export const DEFAULT_SETTINGS: AppSettings = {
@ -128,6 +129,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
aiChatEnabled: false,
// Metadata enrichment
enrichMetadataWithTMDB: true,
useTmdbLocalizedMetadata: false,
};
const SETTINGS_STORAGE_KEY = 'app_settings';

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { Platform } from 'react-native';
import { toast, ToastPosition } from '@backpackapp-io/react-native-toast';
import { Toast } from 'toastify-react-native';
import UpdateService, { UpdateInfo } from '../services/updateService';
import AsyncStorage from '@react-native-async-storage/async-storage';
@ -78,19 +78,13 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
// The app will automatically reload with the new version
console.log('Update installed successfully');
} else {
toast('Unable to install the update. Please try again later or check your internet connection.', {
duration: 3000,
position: ToastPosition.TOP,
});
Toast.error('Unable to install the update. Please try again later or check your internet connection.');
// Show popup again after failed installation
setShowUpdatePopup(true);
}
} catch (error) {
if (__DEV__) console.error('Error installing update:', error);
toast('An error occurred while installing the update. Please try again later.', {
duration: 3000,
position: ToastPosition.TOP,
});
Toast.error('An error occurred while installing the update. Please try again later.');
// Show popup again after error
setShowUpdatePopup(true);
} finally {
@ -141,12 +135,7 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
(async () => {
try { await AsyncStorage.setItem(UPDATE_BADGE_KEY, 'true'); } catch {}
})();
try {
toast('Update available — go to Settings → App Updates', {
duration: 3000,
position: ToastPosition.TOP,
});
} catch {}
try { Toast.info('Update available — go to Settings → App Updates'); } catch {}
setShowUpdatePopup(false);
} else {
setShowUpdatePopup(true);

View file

@ -15,7 +15,7 @@ import { HeaderVisibility } from '../contexts/HeaderVisibility';
import { Stream } from '../types/streams';
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../contexts/ThemeContext';
import { Toasts } from '@backpackapp-io/react-native-toast';
import ToastManager from 'toastify-react-native';
import { PostHogProvider } from 'posthog-react-native';
// Import screens with their proper types
@ -889,6 +889,7 @@ const customFadeInterpolator = ({ current, layouts }: any) => {
const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStackParamList }) => {
const { currentTheme } = useTheme();
const { user, loading } = useAccount();
const insets = useSafeAreaInsets();
// Handle Android-specific optimizations
useEffect(() => {
@ -1344,7 +1345,85 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
</Stack.Navigator>
</View>
</PaperProvider>
<Toasts />
{/* Global toast customization using ThemeContext */}
<ToastManager
position="top"
useModal={false}
theme={'dark'}
// Dimensions
width={'90%'}
minHeight={61}
// Icon defaults
iconFamily="MaterialIcons"
iconSize={22}
icons={{
success: 'check-circle',
error: 'error',
info: 'info',
warn: 'warning',
default: 'notifications',
}}
// Close icon defaults
showCloseIcon={true}
closeIcon={'close'}
closeIconFamily={'MaterialIcons'}
closeIconSize={18}
// Spacing (ensure below safe area)
topOffset={Math.max(8, insets.top + 8)}
bottomOffset={40}
// Styles bound to ThemeContext
style={{
backgroundColor: currentTheme.colors.darkBackground,
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 14,
}}
textStyle={{
color: currentTheme.colors.highEmphasis,
fontWeight: '600',
}}
config={{
default: (props: any) => (
<View style={{
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 12,
padding: 12,
width: '100%'
}}>
<Text style={{ color: currentTheme.colors.highEmphasis, fontWeight: '700' }}>{props.text1}</Text>
{props.text2 ? (
<Text style={{ color: currentTheme.colors.mediumEmphasis, marginTop: 4 }}>{props.text2}</Text>
) : null}
</View>
),
success: (props: any) => (
<View style={{
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 12,
padding: 12,
width: '100%'
}}>
<Text style={{ color: currentTheme.colors.success || '#4CAF50', fontWeight: '800' }}>{props.text1}</Text>
{props.text2 ? (
<Text style={{ color: currentTheme.colors.mediumEmphasis, marginTop: 4 }}>{props.text2}</Text>
) : null}
</View>
),
error: (props: any) => (
<View style={{
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 12,
padding: 12,
width: '100%'
}}>
<Text style={{ color: currentTheme.colors.error || '#ff4444', fontWeight: '800' }}>{props.text1}</Text>
{props.text2 ? (
<Text style={{ color: currentTheme.colors.mediumEmphasis, marginTop: 4 }}>{props.text2}</Text>
) : null}
</View>
),
}}
/>
</SafeAreaProvider>
);
};

View file

@ -7,7 +7,7 @@ import { useTheme } from '../contexts/ThemeContext';
import { useAccount } from '../contexts/AccountContext';
import { useNavigation, useRoute } from '@react-navigation/native';
import * as Haptics from 'expo-haptics';
import { toast } from '@backpackapp-io/react-native-toast';
import ToastManager, { Toast } from 'toastify-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const { width, height } = Dimensions.get('window');
@ -144,21 +144,21 @@ const AuthScreen: React.FC = () => {
if (!isEmailValid) {
const msg = 'Enter a valid email address';
setError(msg);
toast.error(msg);
Toast.error(msg);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
return;
}
if (!isPasswordValid) {
const msg = 'Password must be at least 6 characters';
setError(msg);
toast.error(msg);
Toast.error(msg);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
return;
}
if (mode === 'signup' && !passwordsMatch) {
const msg = 'Passwords do not match';
setError(msg);
toast.error(msg);
Toast.error(msg);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
return;
}
@ -167,11 +167,11 @@ const AuthScreen: React.FC = () => {
const err = mode === 'signin' ? await signIn(email.trim(), password) : await signUp(email.trim(), password);
if (err) {
setError(err);
toast.error(err);
Toast.error(err);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
} else {
const msg = mode === 'signin' ? 'Logged in successfully' : 'Sign up successful';
toast.success(msg);
Toast.success(msg);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
// Navigate to main tabs after successful authentication

View file

@ -58,7 +58,7 @@ import { useLoading } from '../contexts/LoadingContext';
import * as ScreenOrientation from 'expo-screen-orientation';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { toast, ToastPosition } from '@backpackapp-io/react-native-toast';
import { Toast } from 'toastify-react-native';
import FirstTimeWelcome from '../components/FirstTimeWelcome';
import { imageCacheService } from '../services/imageCacheService';
import { HeaderVisibility } from '../contexts/HeaderVisibility';
@ -341,12 +341,7 @@ const HomeScreen = () => {
await AsyncStorage.removeItem('showLoginHintToastOnce');
hideTimer = setTimeout(() => setHintVisible(false), 2000);
// Also show a global toast for consistency across screens
try {
toast('You can sign in anytime from Settings → Account', {
duration: 1600,
position: ToastPosition.BOTTOM,
});
} catch {}
try { Toast.info('You can sign in anytime from Settings → Account', 'bottom'); } catch {}
}
} catch {}
})();
@ -1337,4 +1332,17 @@ const styles = StyleSheet.create<any>({
},
});
export default React.memo(HomeScreen);
import { DeviceEventEmitter } from 'react-native';
const HomeScreenWithFocusSync = (props: any) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
DeviceEventEmitter.emit('watchedStatusChanged');
});
return () => unsubscribe();
}, [navigation]);
return <HomeScreen {...props} />;
};
export default React.memo(HomeScreenWithFocusSync);

View file

@ -1,4 +1,9 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { DeviceEventEmitter } from 'react-native';
import { Share } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Toast } from 'toastify-react-native';
import DropUpMenu from '../components/home/DropUpMenu';
import {
View,
Text,
@ -40,6 +45,7 @@ interface LibraryItem extends StreamingContent {
imdbId?: string;
traktId: number;
images?: TraktImages;
watched?: boolean;
}
interface TraktDisplayItem {
@ -205,6 +211,9 @@ const LibraryScreen = () => {
const [filter, setFilter] = useState<'trakt' | 'movies' | 'series'>('movies');
const [showTraktContent, setShowTraktContent] = useState(false);
const [selectedTraktFolder, setSelectedTraktFolder] = useState<string | null>(null);
// DropUpMenu state
const [menuVisible, setMenuVisible] = useState(false);
const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null);
const insets = useSafeAreaInsets();
const { currentTheme } = useTheme();
@ -267,7 +276,22 @@ const LibraryScreen = () => {
setLoading(true);
try {
const items = await catalogService.getLibraryItems();
setLibraryItems(items as LibraryItem[]);
// Load watched status for each item from AsyncStorage
const updatedItems = await Promise.all(items.map(async (item) => {
// Map StreamingContent to LibraryItem shape
const libraryItem: LibraryItem = {
...item,
gradient: Array.isArray((item as any).gradient) ? (item as any).gradient : ['#222', '#444'],
traktId: typeof (item as any).traktId === 'number' ? (item as any).traktId : 0,
};
const key = `watched:${item.type}:${item.id}`;
const watched = await AsyncStorage.getItem(key);
return {
...libraryItem,
watched: watched === 'true'
};
}));
setLibraryItems(updatedItems);
} catch (error) {
logger.error('Failed to load library:', error);
} finally {
@ -278,14 +302,37 @@ const LibraryScreen = () => {
loadLibrary();
// Subscribe to library updates
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
setLibraryItems(items as LibraryItem[]);
const unsubscribe = catalogService.subscribeToLibraryUpdates(async (items) => {
// Sync watched status on update
const updatedItems = await Promise.all(items.map(async (item) => {
// Map StreamingContent to LibraryItem shape
const libraryItem: LibraryItem = {
...item,
gradient: Array.isArray((item as any).gradient) ? (item as any).gradient : ['#222', '#444'],
traktId: typeof (item as any).traktId === 'number' ? (item as any).traktId : 0,
};
const key = `watched:${item.type}:${item.id}`;
const watched = await AsyncStorage.getItem(key);
return {
...libraryItem,
watched: watched === 'true'
};
}));
setLibraryItems(updatedItems);
});
// Listen for watched status changes
const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', loadLibrary);
// Refresh when screen regains focus
const focusSub = navigation.addListener('focus', loadLibrary);
return () => {
unsubscribe();
watchedSub.remove();
focusSub();
};
}, []);
}, [navigation]);
const filteredItems = libraryItems.filter(item => {
if (filter === 'movies') return item.type === 'movie';
@ -348,17 +395,25 @@ const LibraryScreen = () => {
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
onLongPress={() => {
setSelectedItem(item);
setMenuVisible(true);
}}
activeOpacity={0.7}
>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
transition={300}
/>
{item.watched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success || '#4CAF50'} />
</View>
)}
{item.progress !== undefined && item.progress < 1 && (
<View style={styles.progressBarContainer}>
<View
@ -370,7 +425,7 @@ const LibraryScreen = () => {
</View>
)}
</View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
{item.name}
</Text>
</View>
@ -932,6 +987,62 @@ const LibraryScreen = () => {
{showTraktContent ? renderTraktContent() : renderContent()}
</View>
</View>
{/* DropUpMenu integration */}
{selectedItem && (
<DropUpMenu
visible={menuVisible}
onClose={() => setMenuVisible(false)}
item={selectedItem}
isWatched={!!selectedItem.watched}
isSaved={true} // Since this is from library, it's always saved
onOptionSelect={async (option) => {
if (!selectedItem) return;
switch (option) {
case 'library': {
try {
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
Toast.info('Removed from Library');
setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type)));
setMenuVisible(false);
} catch (error) {
Toast.error('Failed to update Library');
}
break;
}
case 'watched': {
try {
// Use AsyncStorage to store watched status by key
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
const newWatched = !selectedItem.watched;
await AsyncStorage.setItem(key, newWatched ? 'true' : 'false');
Toast.info(newWatched ? 'Marked as Watched' : 'Marked as Unwatched');
// Instantly update local state
setLibraryItems(prev => prev.map(item =>
item.id === selectedItem.id && item.type === selectedItem.type
? { ...item, watched: newWatched }
: item
));
} catch (error) {
Toast.error('Failed to update watched status');
}
break;
}
case 'share': {
let url = '';
if (selectedItem.id) {
url = `https://www.imdb.com/title/${selectedItem.id}/`;
}
const message = `${selectedItem.name}\n${url}`;
Share.share({ message, url, title: selectedItem.name });
break;
}
default:
break;
}
}}
/>
)}
</View>
);
};
@ -947,6 +1058,14 @@ const styles = StyleSheet.create({
right: 0,
zIndex: 1,
},
watchedIndicator: {
position: 'absolute',
top: 8,
right: 8,
borderRadius: 12,
padding: 2,
zIndex: 2,
},
contentContainer: {
flex: 1,
},

View file

@ -25,6 +25,9 @@ import CustomAlert from '../components/CustomAlert';
// TMDB API key - since the default key might be private in the service, we'll use our own
const TMDB_API_KEY = '439c478a771f35c05022f9feabcca01c';
// Extra TMDB logo languages to always offer (only Arabic per request)
const COMMON_TMDB_LANGUAGES: string[] = ['ar'];
// Define example shows with their IMDB IDs and TMDB IDs
const EXAMPLE_SHOWS = [
{
@ -407,6 +410,9 @@ const LogoSourceSettings = () => {
const [tmdbBanner, setTmdbBanner] = useState<string | null>(null);
const [metahubBanner, setMetahubBanner] = useState<string | null>(null);
const [loadingLogos, setLoadingLogos] = useState(true);
// Track which language the preview is actually using and if it is a fallback
const [previewLanguage, setPreviewLanguage] = useState<string>('');
const [isPreviewFallback, setIsPreviewFallback] = useState<boolean>(false);
// State for TMDB language selection
// Store unique language codes as strings
@ -471,6 +477,7 @@ const LogoSourceSettings = () => {
initialLogoPath = preferredLogo.file_path;
initialLanguage = preferredTmdbLanguage;
logger.log(`[LogoSourceSettings] Found initial ${preferredTmdbLanguage} TMDB logo for ${show.name}`);
setIsPreviewFallback(false);
} else {
// Fallback to English logo
const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === 'en');
@ -479,22 +486,27 @@ const LogoSourceSettings = () => {
initialLogoPath = englishLogo.file_path;
initialLanguage = 'en';
logger.log(`[LogoSourceSettings] Found initial English TMDB logo for ${show.name}`);
setIsPreviewFallback(true);
} else if (imagesData.logos[0]) {
// Fallback to the first available logo
initialLogoPath = imagesData.logos[0].file_path;
initialLanguage = imagesData.logos[0].iso_639_1;
logger.log(`[LogoSourceSettings] No English logo, using first available (${initialLanguage}) TMDB logo for ${show.name}`);
setIsPreviewFallback(true);
}
}
if (initialLogoPath) {
setTmdbLogo(`https://image.tmdb.org/t/p/original${initialLogoPath}`);
setPreviewLanguage(initialLanguage || '');
} else {
logger.warn(`[LogoSourceSettings] No valid initial TMDB logo found for ${show.name}`);
}
} else {
logger.warn(`[LogoSourceSettings] No TMDB logos found in response for ${show.name}`);
setUniqueTmdbLanguages([]); // Ensure it's empty if no logos
setPreviewLanguage('');
setIsPreviewFallback(false);
}
// Get TMDB banner (backdrop)
@ -603,8 +615,24 @@ const LogoSourceSettings = () => {
if (selectedLogoData) {
setTmdbLogo(`https://image.tmdb.org/t/p/original${selectedLogoData.file_path}`);
logger.log(`[LogoSourceSettings] Switched TMDB logo preview to language: ${languageCode}`);
setPreviewLanguage(languageCode);
setIsPreviewFallback(false);
} else {
logger.warn(`[LogoSourceSettings] Could not find logo data for selected language: ${languageCode}`);
// Fallback to English, then first available if English is not present
const englishData = tmdbLogosData.find(logo => logo.iso_639_1 === 'en');
if (englishData) {
setTmdbLogo(`https://image.tmdb.org/t/p/original${englishData.file_path}`);
setPreviewLanguage('en');
setIsPreviewFallback(true);
} else if (tmdbLogosData[0]) {
setTmdbLogo(`https://image.tmdb.org/t/p/original${tmdbLogosData[0].file_path}`);
setPreviewLanguage(tmdbLogosData[0].iso_639_1 || '');
setIsPreviewFallback(true);
} else {
setPreviewLanguage('');
setIsPreviewFallback(false);
}
}
}
@ -833,15 +861,18 @@ const LogoSourceSettings = () => {
<View style={styles.exampleContainer}>
<Text style={styles.exampleLabel}>Example:</Text>
{renderLogoExample(tmdbLogo, tmdbBanner, loadingLogos)}
<Text style={styles.logoSourceLabel}>
{`Preview language: ${(previewLanguage || '').toUpperCase() || 'N/A'}${isPreviewFallback ? ' (fallback)' : ''}`}
</Text>
<Text style={styles.logoSourceLabel}>{selectedShow.name} logo from TMDB</Text>
</View>
{/* TMDB Language Selector */}
{uniqueTmdbLanguages.length > 1 && (
{true && (
<View style={styles.languageSelectorContainer}>
<Text style={styles.languageSelectorTitle}>Logo Language</Text>
<Text style={styles.languageSelectorDescription}>
Select your preferred language for TMDB logos.
Select your preferred language for TMDB logos (includes common languages like Arabic even if not shown in this preview).
</Text>
<ScrollView
horizontal
@ -850,8 +881,8 @@ const LogoSourceSettings = () => {
scrollEventThrottle={32}
decelerationRate="normal"
>
{/* Iterate over unique language codes */}
{uniqueTmdbLanguages.map((langCode) => (
{/* Merge unique languages from TMDB with a common list to ensure wider options */}
{Array.from(new Set<string>([...uniqueTmdbLanguages, ...COMMON_TMDB_LANGUAGES])).map((langCode) => (
<TouchableOpacity
key={langCode} // Use the unique code as key
style={[

View file

@ -778,8 +778,8 @@ const InfoTooltip: React.FC<{ text: string; colors: any }> = ({ text, colors })
);
// Helper component for status badges
const StatusBadge: React.FC<{
status: 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error';
const StatusBadge: React.FC<{
status: 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' | 'limited';
colors: any;
}> = ({ status, colors }) => {
const getStatusConfig = () => {
@ -792,6 +792,8 @@ const StatusBadge: React.FC<{
return { color: colors.primary, text: 'Available' };
case 'platform-disabled':
return { color: '#FF9500', text: 'Platform Disabled' };
case 'limited':
return { color: '#FF9500', text: 'Limited' };
case 'error':
return { color: '#FF3B30', text: 'Error' };
default:
@ -919,9 +921,10 @@ const PluginsScreen: React.FC = () => {
}));
};
const getScraperStatus = (scraper: ScraperInfo): 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' => {
const getScraperStatus = (scraper: ScraperInfo): 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' | 'limited' => {
if (scraper.manifestEnabled === false) return 'disabled';
if (scraper.disabledPlatforms?.includes(Platform.OS as 'ios' | 'android')) return 'platform-disabled';
if (scraper.limited) return 'limited';
if (scraper.enabled) return 'enabled';
return 'available';
};
@ -1293,7 +1296,7 @@ const PluginsScreen: React.FC = () => {
};
// Define available quality options
const qualityOptions = ['Auto', '2160p', '4K', '1080p', '720p', '360p', 'DV', 'HDR', 'REMUX', '480p', 'CAM', 'TS'];
const qualityOptions = ['Auto', 'Adaptive', '2160p', '4K', '1080p', '720p', '360p', 'DV', 'HDR', 'REMUX', '480p', 'CAM', 'TS'];
@ -1816,9 +1819,13 @@ const PluginsScreen: React.FC = () => {
<View style={[styles.section, styles.lastSection]}>
<Text style={styles.sectionTitle}>About Plugins</Text>
<Text style={styles.infoText}>
Plugins are JavaScript modules that can search for streaming links from various sources.
Plugins are JavaScript modules that can search for streaming links from various sources.
They run locally on your device and can be installed from trusted repositories.
</Text>
<Text style={[styles.infoText, { marginTop: 8, fontSize: 13, color: colors.mediumEmphasis }]}>
<Text style={{ fontWeight: '600' }}>Note:</Text> Providers marked as "Limited" depend on external APIs that may stop working without notice.
</Text>
</View>
</ScrollView>

View file

@ -24,6 +24,8 @@ import { MaterialIcons } from '@expo/vector-icons';
import { catalogService, StreamingContent } from '../services/catalogService';
import { Image } from 'expo-image';
import debounce from 'lodash/debounce';
import { DropUpMenu } from '../components/home/DropUpMenu';
import { DeviceEventEmitter, Share } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Animated, {
FadeIn,
@ -207,7 +209,26 @@ const SearchScreen = () => {
const inputRef = useRef<TextInput>(null);
const insets = useSafeAreaInsets();
const { currentTheme } = useTheme();
// DropUpMenu state
const [menuVisible, setMenuVisible] = useState(false);
const [selectedItem, setSelectedItem] = useState<StreamingContent | null>(null);
const [isSaved, setIsSaved] = useState(false);
const [isWatched, setIsWatched] = useState(false);
const [refreshFlag, setRefreshFlag] = React.useState(false);
// Update isSaved and isWatched when selectedItem changes
useEffect(() => {
if (!selectedItem) return;
(async () => {
// Check if item is in library
const items = await catalogService.getLibraryItems();
const found = items.find((libItem: any) => libItem.id === selectedItem.id && libItem.type === selectedItem.type);
setIsSaved(!!found);
// Check watched status
const val = await AsyncStorage.getItem(`watched:${selectedItem.type}:${selectedItem.id}`);
setIsWatched(val === 'true');
})();
}, [selectedItem]);
// Animation values
const searchBarWidth = useSharedValue(width - 32);
const searchBarOpacity = useSharedValue(1);
@ -441,35 +462,79 @@ const SearchScreen = () => {
);
};
const renderHorizontalItem = ({ item, index }: { item: StreamingContent, index: number }) => {
const SearchResultItem = ({ item, index, navigation, setSelectedItem, setMenuVisible, currentTheme, refreshFlag }: {
item: StreamingContent;
index: number;
navigation: any;
setSelectedItem: (item: StreamingContent) => void;
setMenuVisible: (visible: boolean) => void;
currentTheme: any;
refreshFlag: boolean;
}) => {
const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary);
const [watched, setWatched] = React.useState(false);
// Re-check status when refreshFlag changes
React.useEffect(() => {
AsyncStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
const items = catalogService.getLibraryItems();
const found = items.find((libItem: any) => libItem.id === item.id && libItem.type === item.type);
setInLibrary(!!found);
}, [refreshFlag, item.id, item.type]);
React.useEffect(() => {
const updateWatched = () => {
AsyncStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
};
updateWatched();
const sub = DeviceEventEmitter.addListener('watchedStatusChanged', updateWatched);
return () => sub.remove();
}, [item.id, item.type]);
React.useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type);
setInLibrary(!!found);
});
return () => unsubscribe();
}, [item.id, item.type]);
return (
<AnimatedTouchable
style={styles.horizontalItem}
onPress={() => {
navigation.navigate('Metadata', { id: item.id, type: item.type });
}}
onLongPress={() => {
setSelectedItem(item);
setMenuVisible(true);
// Do NOT toggle refreshFlag here
}}
delayLongPress={300}
entering={FadeIn.duration(300).delay(index * 50)}
activeOpacity={0.7}
>
<View style={[styles.horizontalItemPosterContainer, {
backgroundColor: currentTheme.colors.darkBackground,
borderColor: 'rgba(255,255,255,0.05)'
}]}>
}]}>
<Image
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
style={styles.horizontalItemPoster}
contentFit="cover"
transition={300}
/>
<View style={styles.itemTypeContainer}>
<Text style={[styles.itemTypeText, { color: currentTheme.colors.white }]}>
{item.type === 'movie' ? 'MOVIE' : 'SERIES'}
</Text>
</View>
{/* Bookmark and watched icons top right, bookmark to the left of watched */}
{inLibrary && (
<View style={[styles.libraryBadge, { position: 'absolute', top: 8, right: 36, backgroundColor: 'transparent', zIndex: 2 }] }>
<MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
{watched && (
<View style={[styles.watchedIndicator, { position: 'absolute', top: 8, right: 8, backgroundColor: 'transparent', zIndex: 2 }] }>
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} />
</View>
)}
{item.imdbRating && (
<View style={styles.ratingContainer}>
<MaterialIcons name="star" size={12} color="#FFC107" />
<Text style={[styles.ratingText, { color: currentTheme.colors.white }]}>
<Text style={[styles.ratingText, { color: currentTheme.colors.white }]}>
{item.imdbRating}
</Text>
</View>
@ -482,7 +547,7 @@ const SearchScreen = () => {
{item.name}
</Text>
{item.year && (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray }]}>
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray }]}>
{item.year}
</Text>
)}
@ -506,6 +571,17 @@ const SearchScreen = () => {
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
const headerHeight = headerBaseHeight + topSpacing + 60;
useEffect(() => {
const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', () => setRefreshFlag(f => !f));
const librarySub = catalogService.subscribeToLibraryUpdates(() => setRefreshFlag(f => !f));
const focusSub = navigation.addListener('focus', () => setRefreshFlag(f => !f));
return () => {
watchedSub.remove();
librarySub();
focusSub();
};
}, []);
return (
<Animated.View
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
@ -520,13 +596,11 @@ const SearchScreen = () => {
backgroundColor="transparent"
translucent
/>
{/* Fixed position header background to prevent shifts */}
<View style={[styles.headerBackground, {
height: headerHeight,
backgroundColor: currentTheme.colors.darkBackground
}]} />
<View style={{ flex: 1 }}>
{/* Header Section with proper top spacing */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
@ -580,7 +654,6 @@ const SearchScreen = () => {
</View>
</View>
</View>
{/* Content Container */}
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{searching ? (
@ -600,10 +673,10 @@ const SearchScreen = () => {
size={64}
color={currentTheme.colors.lightGray}
/>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
Keep typing...
</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
Type at least 2 characters to search
</Text>
</Animated.View>
@ -617,10 +690,10 @@ const SearchScreen = () => {
size={64}
color={currentTheme.colors.lightGray}
/>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
No results found
</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
Try different keywords or check your spelling
</Text>
</Animated.View>
@ -634,48 +707,110 @@ const SearchScreen = () => {
showsVerticalScrollIndicator={false}
>
{!query.trim() && renderRecentSearches()}
{movieResults.length > 0 && (
<Animated.View
style={styles.carouselContainer}
entering={FadeIn.duration(300)}
>
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
Movies ({movieResults.length})
</Text>
<FlatList
data={movieResults}
renderItem={renderHorizontalItem}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
navigation={navigation}
setSelectedItem={setSelectedItem}
setMenuVisible={setMenuVisible}
currentTheme={currentTheme}
refreshFlag={refreshFlag}
/>
)}
keyExtractor={item => `movie-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
extraData={refreshFlag}
/>
</Animated.View>
)}
{seriesResults.length > 0 && (
<Animated.View
style={styles.carouselContainer}
entering={FadeIn.duration(300).delay(50)}
>
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
TV Shows ({seriesResults.length})
</Text>
<FlatList
data={seriesResults}
renderItem={renderHorizontalItem}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
navigation={navigation}
setSelectedItem={setSelectedItem}
setMenuVisible={setMenuVisible}
currentTheme={currentTheme}
refreshFlag={refreshFlag}
/>
)}
keyExtractor={item => `series-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
extraData={refreshFlag}
/>
</Animated.View>
)}
</Animated.ScrollView>
)}
</View>
{/* DropUpMenu integration for search results */}
{selectedItem && (
<DropUpMenu
visible={menuVisible}
onClose={() => setMenuVisible(false)}
item={selectedItem}
isSaved={isSaved}
isWatched={isWatched}
onOptionSelect={async (option: string) => {
if (!selectedItem) return;
switch (option) {
case 'share': {
let url = '';
if (selectedItem.id) {
url = `https://www.imdb.com/title/${selectedItem.id}/`;
}
const message = `${selectedItem.name}\n${url}`;
Share.share({ message, url, title: selectedItem.name });
break;
}
case 'library': {
if (isSaved) {
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
setIsSaved(false);
} else {
await catalogService.addToLibrary(selectedItem);
setIsSaved(true);
}
break;
}
case 'watched': {
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
const newWatched = !isWatched;
await AsyncStorage.setItem(key, newWatched ? 'true' : 'false');
setIsWatched(newWatched);
break;
}
default:
break;
}
}}
/>
)}
</View>
</Animated.View>
);
@ -897,19 +1032,6 @@ const styles = StyleSheet.create({
marginBottom: 16,
borderRadius: 4,
},
itemTypeContainer: {
position: 'absolute',
top: 8,
left: 8,
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
itemTypeText: {
fontSize: isTablet ? 7 : 8,
fontWeight: '700',
},
ratingContainer: {
position: 'absolute',
bottom: 8,
@ -954,6 +1076,24 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
watchedIndicator: {
position: 'absolute',
top: 8,
right: 8,
borderRadius: 12,
padding: 2,
zIndex: 2,
backgroundColor: 'transparent',
},
libraryBadge: {
position: 'absolute',
top: 8,
right: 36,
borderRadius: 8,
padding: 4,
zIndex: 2,
backgroundColor: 'transparent',
},
});
export default SearchScreen;

View file

@ -37,17 +37,18 @@ import { useMetadata } from '../hooks/useMetadata';
import { useMetadataAssets } from '../hooks/useMetadataAssets';
import { useTheme } from '../contexts/ThemeContext';
import { useTrailer } from '../contexts/TrailerContext';
import { Stream } from '../types/metadata';
import { Stream, GroupedStreams } from '../types/metadata';
import { tmdbService } from '../services/tmdbService';
import { stremioService } from '../services/stremioService';
import { localScraperService } from '../services/localScraperService';
import { hybridCacheService } from '../services/hybridCacheService';
import { VideoPlayerService } from '../services/videoPlayerService';
import { useSettings } from '../hooks/useSettings';
import QualityBadge from '../components/metadata/QualityBadge';
import { logger } from '../utils/logger';
import { isMkvStream } from '../utils/mkvDetection';
import CustomAlert from '../components/CustomAlert';
import { toast, ToastPosition } from '@backpackapp-io/react-native-toast';
import { Toast } from 'toastify-react-native';
import { useDownloads } from '../contexts/DownloadsContext';
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
@ -233,10 +234,7 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
// Use toast for Android, custom alert for iOS
if (Platform.OS === 'android') {
toast('Stream URL copied to clipboard!', {
duration: 2000,
position: ToastPosition.BOTTOM,
});
Toast.success('Stream URL copied to clipboard!', 'bottom');
} else {
// iOS uses custom alert
setTimeout(() => {
@ -246,10 +244,7 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
} catch (error) {
// Fallback: show URL in alert if clipboard fails
if (Platform.OS === 'android') {
toast(`Stream URL: ${stream.url}`, {
duration: 3000,
position: ToastPosition.BOTTOM,
});
Toast.info(`Stream URL: ${stream.url}`, 'bottom');
} else {
setTimeout(() => {
showAlert('Stream URL', stream.url);
@ -322,7 +317,7 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
url,
headers: (stream.headers as any) || undefined,
});
toast('Download started', { duration: 1500, position: ToastPosition.BOTTOM });
Toast.success('Download started', 'bottom');
} catch {}
}, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title]);
@ -504,10 +499,11 @@ export const StreamsScreen = () => {
const { colors } = currentTheme;
const { pauseTrailer, resumeTrailer } = useTrailer();
// Add ref to prevent excessive updates
// Add refs to prevent excessive updates and duplicate loads
const isMounted = useRef(true);
const loadStartTimeRef = useRef(0);
const hasDoneInitialLoadRef = useRef(false);
const isLoadingStreamsRef = useRef(false);
// CustomAlert state
const [alertVisible, setAlertVisible] = useState(false);
@ -733,81 +729,233 @@ export const StreamsScreen = () => {
}
}, [selectedProvider, availableProviders, episodeStreams, groupedStreams, type]);
// Check for cached results immediately on mount
useEffect(() => {
const checkCachedResults = async () => {
if (!settings.enableLocalScrapers) return;
try {
let season: number | undefined;
let episode: number | undefined;
if (episodeId && episodeId.includes(':')) {
const parts = episodeId.split(':');
if (parts.length >= 3) {
season = parseInt(parts[1], 10);
episode = parseInt(parts[2], 10);
}
}
const installedScrapers = await localScraperService.getInstalledScrapers();
const userSettings = {
enableLocalScrapers: settings.enableLocalScrapers,
enabledScrapers: new Set(
installedScrapers
.filter(scraper => scraper.enabled)
.map(scraper => scraper.id)
)
};
const cachedResults = await hybridCacheService.getCachedResults(type, id, season, episode, userSettings);
if (cachedResults.validResults.length > 0) {
logger.log(`🔍 Found ${cachedResults.validResults.length} cached scraper results on mount`);
// If we have cached results, trigger the loading flow immediately
if (!hasDoneInitialLoadRef.current) {
logger.log('🚀 Triggering immediate load due to cached results');
// Force a re-render to ensure cached results are displayed
setHasStreamProviders(true);
setStreamsLoadStart(Date.now());
}
}
} catch (error) {
if (__DEV__) console.log('[StreamsScreen] Error checking cached results on mount:', error);
}
};
checkCachedResults();
}, [type, id, episodeId, settings.enableLocalScrapers]);
// Update useEffect to check for sources
useEffect(() => {
// Reset initial load state when content changes
hasDoneInitialLoadRef.current = false;
isLoadingStreamsRef.current = false;
const checkProviders = async () => {
if (__DEV__) console.log('[StreamsScreen] checkProviders() start', { id, type, episodeId, fromPlayer });
logger.log(`[StreamsScreen] checkProviders() start id=${id} type=${type} episodeId=${episodeId || 'none'} fromPlayer=${!!fromPlayer}`);
// Check for Stremio addons
const hasStremioProviders = await stremioService.hasStreamProviders();
if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders);
// Check for local scrapers (only if enabled in settings)
const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers();
if (__DEV__) console.log('[StreamsScreen] hasLocalScrapers:', hasLocalScrapers, 'enableLocalScrapers:', settings.enableLocalScrapers);
// We have providers if we have either Stremio addons OR enabled local scrapers
const hasProviders = hasStremioProviders || hasLocalScrapers;
logger.log(`[StreamsScreen] provider check: hasProviders=${hasProviders}`);
if (!isMounted.current) return;
// Prevent duplicate calls if already loading
if (isLoadingStreamsRef.current) {
if (__DEV__) console.log('[StreamsScreen] checkProviders() skipping - already loading');
return;
}
setHasStreamProviders(hasProviders);
setHasStremioStreamProviders(hasStremioProviders);
isLoadingStreamsRef.current = true;
if (!hasProviders) {
logger.log('[StreamsScreen] No providers detected; scheduling no-sources UI');
const timer = setTimeout(() => {
if (isMounted.current) setShowNoSourcesError(true);
}, 500);
return () => clearTimeout(timer);
} else {
// For series episodes, do not wait for metadata; load directly when episodeId is present
if (episodeId) {
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
setLoadingProviders({
'stremio': true
});
setSelectedEpisode(episodeId);
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadEpisodeStreams', episodeId);
loadEpisodeStreams(episodeId);
} else if (type === 'movie') {
logger.log(`🎬 Loading movie streams for: ${id}`);
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (movie)', id);
loadStreams();
} else if (type === 'tv') {
// TV/live content fetch streams directly
logger.log(`📺 Loading TV streams for: ${id}`);
setLoadingProviders({
'stremio': true
});
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (tv)', id);
loadStreams();
} else {
// Fallback: series without explicit episodeId (or other types) fetch streams directly
logger.log(`🎬 Loading streams for: ${id}`);
setLoadingProviders({
'stremio': true
});
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (fallback)', id);
loadStreams();
}
// Reset autoplay state when content changes
setAutoplayTriggered(false);
if (settings.autoplayBestStream && !fromPlayer) {
setIsAutoplayWaiting(true);
logger.log('🔄 Autoplay enabled, waiting for best stream...');
} else {
setIsAutoplayWaiting(false);
if (fromPlayer) {
logger.log('🚫 Autoplay disabled: returning from player');
try {
// Check for Stremio addons
const hasStremioProviders = await stremioService.hasStreamProviders();
if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders);
// Check for local scrapers (only if enabled in settings)
const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers();
if (__DEV__) console.log('[StreamsScreen] hasLocalScrapers:', hasLocalScrapers, 'enableLocalScrapers:', settings.enableLocalScrapers);
// Check for cached results (this covers both local and global cache)
let hasCachedResults = false;
if (settings.enableLocalScrapers) {
try {
// Check if there are any cached streams for this content
let season: number | undefined;
let episode: number | undefined;
if (episodeId && episodeId.includes(':')) {
const parts = episodeId.split(':');
if (parts.length >= 3) {
season = parseInt(parts[1], 10);
episode = parseInt(parts[2], 10);
}
}
const installedScrapers = await localScraperService.getInstalledScrapers();
const userSettings = {
enableLocalScrapers: settings.enableLocalScrapers,
enabledScrapers: new Set(
installedScrapers
.filter(scraper => scraper.enabled)
.map(scraper => scraper.id)
)
};
const cachedStreams = await hybridCacheService.getCachedStreams(type, id, season, episode, userSettings);
hasCachedResults = cachedStreams.length > 0;
if (__DEV__) console.log('[StreamsScreen] hasCachedResults:', hasCachedResults, 'cached streams count:', cachedStreams.length, 'season:', season, 'episode:', episode);
} catch (error) {
if (__DEV__) console.log('[StreamsScreen] Error checking cached results:', error);
}
}
// We have providers if we have Stremio addons, enabled local scrapers, OR cached results
const hasProviders = hasStremioProviders || hasLocalScrapers || hasCachedResults;
logger.log(`[StreamsScreen] provider check: hasProviders=${hasProviders} (stremio:${hasStremioProviders}, local:${hasLocalScrapers}, cached:${hasCachedResults})`);
if (!isMounted.current) return;
setHasStreamProviders(hasProviders);
setHasStremioStreamProviders(hasStremioProviders);
if (!hasProviders) {
// If we have local scrapers enabled but no cached results yet, wait a bit longer
if (settings.enableLocalScrapers && !hasCachedResults) {
logger.log('[StreamsScreen] No providers detected but checking for cached results; waiting longer');
const timer = setTimeout(() => {
if (isMounted.current) setShowNoSourcesError(true);
}, 2000); // Wait 2 seconds for cached results
return () => clearTimeout(timer);
} else {
logger.log('[StreamsScreen] No providers detected; scheduling no-sources UI');
const timer = setTimeout(() => {
if (isMounted.current) setShowNoSourcesError(true);
}, 500);
return () => clearTimeout(timer);
}
} else {
// Check for cached streams first before loading
if (settings.enableLocalScrapers) {
try {
let season: number | undefined;
let episode: number | undefined;
if (episodeId && episodeId.includes(':')) {
const parts = episodeId.split(':');
if (parts.length >= 3) {
season = parseInt(parts[1], 10);
episode = parseInt(parts[2], 10);
}
}
// Check if we have cached streams and load them immediately
const cachedStreams = await hybridCacheService.getCachedStreams(type, id, season, episode);
if (cachedStreams.length > 0) {
logger.log(`🎯 Found ${cachedStreams.length} cached streams, displaying immediately`);
// Group cached streams by scraper for proper display
const groupedCachedStreams: GroupedStreams = {};
const scrapersWithCachedResults = new Set<string>();
// Get cached results to determine which scrapers have results
const cachedResults = await hybridCacheService.getCachedResults(type, id, season, episode);
for (const result of cachedResults.validResults) {
if (result.success && result.streams && result.streams.length > 0) {
groupedCachedStreams[result.scraperId] = {
addonName: result.scraperName,
streams: result.streams
};
scrapersWithCachedResults.add(result.scraperId);
}
}
// Update the streams state immediately if we have cached results
if (Object.keys(groupedCachedStreams).length > 0) {
logger.log(`🚀 Immediately displaying ${Object.keys(groupedCachedStreams).length} cached scrapers with streams`);
// This will be handled by the useMetadata hook integration
}
}
} catch (error) {
if (__DEV__) console.log('[StreamsScreen] Error checking cached streams:', error);
}
}
// For series episodes, do not wait for metadata; load directly when episodeId is present
if (episodeId) {
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
setLoadingProviders({
'stremio': true
});
setSelectedEpisode(episodeId);
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadEpisodeStreams', episodeId);
loadEpisodeStreams(episodeId);
} else if (type === 'movie') {
logger.log(`🎬 Loading movie streams for: ${id}`);
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (movie)', id);
loadStreams();
} else if (type === 'tv') {
// TV/live content fetch streams directly
logger.log(`📺 Loading TV streams for: ${id}`);
setLoadingProviders({
'stremio': true
});
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (tv)', id);
loadStreams();
} else {
// Fallback: series without explicit episodeId (or other types) fetch streams directly
logger.log(`🎬 Loading streams for: ${id}`);
setLoadingProviders({
'stremio': true
});
setStreamsLoadStart(Date.now());
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (fallback)', id);
loadStreams();
}
// Reset autoplay state when content changes
setAutoplayTriggered(false);
if (settings.autoplayBestStream && !fromPlayer) {
setIsAutoplayWaiting(true);
logger.log('🔄 Autoplay enabled, waiting for best stream...');
} else {
setIsAutoplayWaiting(false);
if (fromPlayer) {
logger.log('🚫 Autoplay disabled: returning from player');
}
}
}
} finally {
isLoadingStreamsRef.current = false;
}
};

View file

@ -17,6 +17,7 @@ import {
Image,
KeyboardAvoidingView,
TouchableWithoutFeedback,
Modal,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
@ -50,6 +51,8 @@ const TMDBSettingsScreen = () => {
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const { settings, updateSetting } = useSettings();
const [languagePickerVisible, setLanguagePickerVisible] = useState(false);
const [languageSearch, setLanguageSearch] = useState('');
const openAlert = (
title: string,
@ -284,165 +287,431 @@ const TMDBSettingsScreen = () => {
</Text>
</View>
<ScrollView
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.switchTextContainer}>
<Text style={[styles.switchTitle, { color: currentTheme.colors.text }]}>Enrich Metadata with TMDb</Text>
<Text style={[styles.switchDescription, { color: currentTheme.colors.mediumEmphasis }]}>When enabled, the app augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback. Disable to strictly use addon metadata only.</Text>
{/* Metadata Enrichment Section */}
<View style={[styles.sectionCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.sectionHeader}>
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Metadata Enrichment</Text>
</View>
<Switch
value={settings.enrichMetadataWithTMDB}
onValueChange={(v) => updateSetting('enrichMetadataWithTMDB', v)}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (settings.enrichMetadataWithTMDB ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
<View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.switchTextContainer}>
<Text style={[styles.switchTitle, { color: currentTheme.colors.text }]}>Use Custom TMDb API Key</Text>
<Text style={[styles.switchDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Enable to use your own TMDb API key instead of the built-in one.
Using your own API key may provide better performance and higher rate limits.
</Text>
</View>
<Switch
value={useCustomKey}
onValueChange={toggleUseCustomKey}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Enhance your content metadata with TMDb data for better details and information.
</Text>
{useCustomKey && (
<>
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<MaterialIcons
name={isKeySet ? "check-circle" : "error-outline"}
size={28}
color={isKeySet ? currentTheme.colors.success : currentTheme.colors.warning}
style={styles.statusIconContainer}
/>
<View style={styles.statusTextContainer}>
<Text style={[styles.statusTitle, { color: currentTheme.colors.text }]}>
{isKeySet ? "API Key Active" : "API Key Required"}
</Text>
<Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
{isKeySet
? "Your custom TMDb API key is set and active."
: "Add your TMDb API key below."}
</Text>
</View>
</View>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.text }]}>API Key</Text>
<View style={styles.inputContainer}>
<TextInput
ref={apiKeyInputRef}
style={[
styles.input,
{
backgroundColor: currentTheme.colors.elevation1,
color: currentTheme.colors.text,
borderColor: isInputFocused ? currentTheme.colors.primary : 'transparent'
}
]}
value={apiKey}
onChangeText={(text) => {
setApiKey(text);
if (testResult) setTestResult(null);
}}
placeholder="Paste your TMDb API key (v3)"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
/>
<TouchableOpacity
style={styles.pasteButton}
onPress={pasteFromClipboard}
>
<MaterialIcons name="content-paste" size={20} color={currentTheme.colors.primary} />
</TouchableOpacity>
</View>
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={saveApiKey}
>
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save API Key</Text>
</TouchableOpacity>
{isKeySet && (
<TouchableOpacity
style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
onPress={clearApiKey}
>
<Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
</TouchableOpacity>
)}
</View>
{testResult && (
<View style={[
styles.resultMessage,
{ backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' }
]}>
<MaterialIcons
name={testResult.success ? "check-circle" : "error"}
size={18}
color={testResult.success ? currentTheme.colors.success : currentTheme.colors.error}
style={styles.resultIcon}
/>
<Text style={[
styles.resultText,
{ color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error }
]}>
{testResult.message}
</Text>
</View>
)}
<TouchableOpacity
style={styles.helpLink}
onPress={openTMDBWebsite}
>
<MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} />
<Text style={[styles.helpText, { color: currentTheme.colors.primary }]}>
How to get a TMDb API key?
</Text>
</TouchableOpacity>
</View>
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
To get your own TMDb API key (v3), you need to create a TMDb account and request an API key from their website.
Using your own API key gives you dedicated quota and may improve app performance.
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Enable Enrichment</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback.
</Text>
</View>
</>
)}
{!useCustomKey && (
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
Currently using the built-in TMDb API key. This key is shared among all users.
For better performance and reliability, consider using your own API key.
</Text>
<Switch
value={settings.enrichMetadataWithTMDB}
onValueChange={(v) => updateSetting('enrichMetadataWithTMDB', v)}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (settings.enrichMetadataWithTMDB ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
)}
{settings.enrichMetadataWithTMDB && (
<>
<View style={styles.divider} />
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Localized Text</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Fetch titles and descriptions in your preferred language from TMDb.
</Text>
</View>
<Switch
value={settings.useTmdbLocalizedMetadata}
onValueChange={(v) => updateSetting('useTmdbLocalizedMetadata', v)}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (settings.useTmdbLocalizedMetadata ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
{settings.useTmdbLocalizedMetadata && (
<>
<View style={styles.divider} />
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Language</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Current: {(settings.tmdbLanguagePreference || 'en').toUpperCase()}
</Text>
</View>
<TouchableOpacity
onPress={() => setLanguagePickerVisible(true)}
style={[styles.languageButton, { backgroundColor: currentTheme.colors.primary }]}
>
<Text style={[styles.languageButtonText, { color: currentTheme.colors.white }]}>Change</Text>
</TouchableOpacity>
</View>
</>
)}
</>
)}
</View>
{/* API Configuration Section */}
<View style={[styles.sectionCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.sectionHeader}>
<MaterialIcons name="api" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>API Configuration</Text>
</View>
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Configure your TMDb API access for enhanced functionality.
</Text>
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Custom API Key</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Use your own TMDb API key for better performance and dedicated rate limits.
</Text>
</View>
<Switch
value={useCustomKey}
onValueChange={toggleUseCustomKey}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
{useCustomKey && (
<>
<View style={styles.divider} />
{/* API Key Status */}
<View style={styles.statusRow}>
<MaterialIcons
name={isKeySet ? "check-circle" : "error-outline"}
size={20}
color={isKeySet ? currentTheme.colors.success : currentTheme.colors.warning}
/>
<Text style={[styles.statusText, {
color: isKeySet ? currentTheme.colors.success : currentTheme.colors.warning
}]}>
{isKeySet ? "Custom API key active" : "API key required"}
</Text>
</View>
{/* API Key Input */}
<View style={styles.apiKeyContainer}>
<View style={styles.inputContainer}>
<TextInput
ref={apiKeyInputRef}
style={[
styles.input,
{
backgroundColor: currentTheme.colors.elevation1,
color: currentTheme.colors.text,
borderColor: isInputFocused ? currentTheme.colors.primary : 'transparent'
}
]}
value={apiKey}
onChangeText={(text) => {
setApiKey(text);
if (testResult) setTestResult(null);
}}
placeholder="Paste your TMDb API key (v3)"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
/>
<TouchableOpacity
style={styles.pasteButton}
onPress={pasteFromClipboard}
>
<MaterialIcons name="content-paste" size={20} color={currentTheme.colors.primary} />
</TouchableOpacity>
</View>
<View style={styles.buttonRow}>
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={saveApiKey}
>
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save</Text>
</TouchableOpacity>
{isKeySet && (
<TouchableOpacity
style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
onPress={clearApiKey}
>
<Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
</TouchableOpacity>
)}
</View>
{testResult && (
<View style={[
styles.resultMessage,
{ backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' }
]}>
<MaterialIcons
name={testResult.success ? "check-circle" : "error"}
size={16}
color={testResult.success ? currentTheme.colors.success : currentTheme.colors.error}
style={styles.resultIcon}
/>
<Text style={[
styles.resultText,
{ color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error }
]}>
{testResult.message}
</Text>
</View>
)}
<TouchableOpacity
style={styles.helpLink}
onPress={openTMDBWebsite}
>
<MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} />
<Text style={[styles.helpText, { color: currentTheme.colors.primary }]}>
How to get a TMDb API key?
</Text>
</TouchableOpacity>
</View>
</>
)}
{!useCustomKey && (
<View style={styles.infoContainer}>
<MaterialIcons name="info-outline" size={18} color={currentTheme.colors.primary} />
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
Currently using built-in API key. Consider using your own key for better performance.
</Text>
</View>
)}
</View>
{/* Language Picker Modal */}
<Modal
visible={languagePickerVisible}
transparent
animationType="slide"
onRequestClose={() => setLanguagePickerVisible(false)}
>
<TouchableWithoutFeedback onPress={() => setLanguagePickerVisible(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback>
<View style={[styles.modalContent, { backgroundColor: currentTheme.colors.darkBackground }]}>
{/* Header */}
<View style={styles.modalHeader}>
<View style={[styles.dragHandle, { backgroundColor: currentTheme.colors.elevation3 }]} />
<Text style={[styles.modalTitle, { color: currentTheme.colors.text }]}>Choose Language</Text>
<Text style={[styles.modalSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>Select your preferred language for TMDb content</Text>
</View>
{/* Search Section */}
<View style={styles.searchSection}>
<View style={[styles.searchContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="search" size={20} color={currentTheme.colors.mediumEmphasis} style={styles.searchIcon} />
<TextInput
placeholder="Search languages..."
placeholderTextColor={currentTheme.colors.mediumEmphasis}
style={[styles.searchInput, { color: currentTheme.colors.text }]}
value={languageSearch}
onChangeText={setLanguageSearch}
autoCapitalize="none"
autoCorrect={false}
/>
{languageSearch.length > 0 && (
<TouchableOpacity onPress={() => setLanguageSearch('')} style={styles.searchClearButton}>
<MaterialIcons name="close" size={20} color={currentTheme.colors.mediumEmphasis} />
</TouchableOpacity>
)}
</View>
</View>
{/* Popular Languages */}
{languageSearch.length === 0 && (
<View style={styles.popularSection}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis }]}>Popular</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.popularChips}
>
{[
{ code: 'en', label: 'EN' },
{ code: 'ar', label: 'AR' },
{ code: 'es', label: 'ES' },
{ code: 'fr', label: 'FR' },
{ code: 'de', label: 'DE' },
{ code: 'tr', label: 'TR' },
].map(({ code, label }) => (
<TouchableOpacity
key={code}
onPress={() => { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }}
style={[
styles.popularChip,
settings.tmdbLanguagePreference === code && styles.selectedChip,
{
backgroundColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.elevation1,
borderColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : 'rgba(255,255,255,0.1)',
}
]}
>
<Text style={[
styles.popularChipText,
settings.tmdbLanguagePreference === code && styles.selectedChipText,
{ color: settings.tmdbLanguagePreference === code ? currentTheme.colors.white : currentTheme.colors.text }
]}>
{label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
)}
{/* All Languages */}
<View style={styles.languagesSection}>
<Text style={[
styles.sectionTitle,
languageSearch.length > 0 && styles.searchResultsTitle,
{ color: languageSearch.length > 0 ? currentTheme.colors.text : currentTheme.colors.mediumEmphasis }
]}>
{languageSearch.length > 0 ? 'Search Results' : 'All Languages'}
</Text>
<ScrollView style={styles.languageList} showsVerticalScrollIndicator={false}>
{(() => {
const languages = [
{ code: 'en', label: 'English', native: 'English' },
{ code: 'ar', label: 'العربية', native: 'Arabic' },
{ code: 'es', label: 'Español', native: 'Spanish' },
{ code: 'fr', label: 'Français', native: 'French' },
{ code: 'de', label: 'Deutsch', native: 'German' },
{ code: 'it', label: 'Italiano', native: 'Italian' },
{ code: 'pt', label: 'Português', native: 'Portuguese' },
{ code: 'ru', label: 'Русский', native: 'Russian' },
{ code: 'tr', label: 'Türkçe', native: 'Turkish' },
{ code: 'ja', label: '日本語', native: 'Japanese' },
{ code: 'ko', label: '한국어', native: 'Korean' },
{ code: 'zh', label: '中文', native: 'Chinese' },
{ code: 'hi', label: 'हिन्दी', native: 'Hindi' },
{ code: 'he', label: 'עברית', native: 'Hebrew' },
{ code: 'id', label: 'Bahasa Indonesia', native: 'Indonesian' },
{ code: 'nl', label: 'Nederlands', native: 'Dutch' },
{ code: 'sv', label: 'Svenska', native: 'Swedish' },
{ code: 'no', label: 'Norsk', native: 'Norwegian' },
{ code: 'da', label: 'Dansk', native: 'Danish' },
{ code: 'fi', label: 'Suomi', native: 'Finnish' },
{ code: 'pl', label: 'Polski', native: 'Polish' },
{ code: 'cs', label: 'Čeština', native: 'Czech' },
{ code: 'ro', label: 'Română', native: 'Romanian' },
{ code: 'uk', label: 'Українська', native: 'Ukrainian' },
{ code: 'vi', label: 'Tiếng Việt', native: 'Vietnamese' },
{ code: 'th', label: 'ไทย', native: 'Thai' },
];
const filteredLanguages = languages.filter(({ label, code, native }) =>
(languageSearch || '').length === 0 ||
label.toLowerCase().includes(languageSearch.toLowerCase()) ||
native.toLowerCase().includes(languageSearch.toLowerCase()) ||
code.toLowerCase().includes(languageSearch.toLowerCase())
);
return (
<>
{filteredLanguages.map(({ code, label, native }) => (
<TouchableOpacity
key={code}
onPress={() => { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }}
style={[
styles.languageItem,
settings.tmdbLanguagePreference === code && styles.selectedLanguageItem
]}
activeOpacity={0.7}
>
<View style={styles.languageContent}>
<View style={styles.languageInfo}>
<Text style={[
styles.languageName,
settings.tmdbLanguagePreference === code && styles.selectedLanguageName,
{
color: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.text,
}
]}>
{native}
</Text>
<Text style={[
styles.languageCode,
settings.tmdbLanguagePreference === code && styles.selectedLanguageCode,
{
color: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.mediumEmphasis,
}
]}>
{label} {code.toUpperCase()}
</Text>
</View>
{settings.tmdbLanguagePreference === code && (
<View style={styles.checkmarkContainer}>
<MaterialIcons name="check-circle" size={24} color={currentTheme.colors.primary} />
</View>
)}
</View>
</TouchableOpacity>
))}
{languageSearch.length > 0 && filteredLanguages.length === 0 && (
<View style={styles.noResultsContainer}>
<MaterialIcons name="search-off" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.noResultsText, { color: currentTheme.colors.mediumEmphasis }]}>
No languages found for "{languageSearch}"
</Text>
<TouchableOpacity
onPress={() => setLanguageSearch('')}
style={[styles.clearSearchButton, { backgroundColor: currentTheme.colors.elevation1 }]}
>
<Text style={[styles.clearSearchButtonText, { color: currentTheme.colors.primary }]}>Clear search</Text>
</TouchableOpacity>
</View>
)}
</>
);
})()}
</ScrollView>
</View>
{/* Footer Actions */}
<View style={styles.modalFooter}>
<TouchableOpacity
onPress={() => setLanguagePickerVisible(false)}
style={styles.cancelButton}
>
<Text style={[styles.cancelButtonText, { color: currentTheme.colors.text }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setLanguagePickerVisible(false)}
style={[styles.doneButton, { backgroundColor: currentTheme.colors.primary }]}
>
<Text style={[styles.doneButtonText, { color: currentTheme.colors.white }]}>Done</Text>
</TouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
</ScrollView>
<CustomAlert
visible={alertVisible}
@ -502,73 +771,86 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingBottom: 40,
},
switchCard: {
sectionCard: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 20,
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
switchTextContainer: {
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
marginLeft: 8,
},
sectionDescription: {
fontSize: 14,
lineHeight: 20,
marginBottom: 20,
},
settingRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 16,
},
settingTextContainer: {
flex: 1,
marginRight: 16,
},
switchTitle: {
settingTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
switchDescription: {
settingDescription: {
fontSize: 14,
lineHeight: 20,
opacity: 0.8,
},
statusCard: {
flexDirection: 'row',
borderRadius: 16,
marginBottom: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
languageButton: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
alignItems: 'center',
},
statusIconContainer: {
marginRight: 12,
},
statusTextContainer: {
flex: 1,
},
statusTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
statusDescription: {
languageButtonText: {
fontSize: 14,
opacity: 0.8,
},
card: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
cardTitle: {
fontSize: 16,
fontWeight: '600',
},
divider: {
height: 1,
backgroundColor: 'rgba(255,255,255,0.1)',
marginVertical: 16,
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
statusText: {
fontSize: 14,
fontWeight: '500',
marginLeft: 8,
},
apiKeyContainer: {
marginTop: 16,
},
infoContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
marginTop: 16,
padding: 12,
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 8,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
@ -600,14 +882,6 @@ const styles = StyleSheet.create({
flex: 1,
marginRight: 8,
},
clearButton: {
backgroundColor: 'transparent',
borderWidth: 2,
marginRight: 0,
marginLeft: 8,
flex: 0,
paddingHorizontal: 16,
},
buttonText: {
fontWeight: '600',
fontSize: 15,
@ -640,27 +914,204 @@ const styles = StyleSheet.create({
fontSize: 14,
fontWeight: '500',
},
infoCard: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'flex-start',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
infoIcon: {
marginRight: 12,
marginTop: 2,
},
infoText: {
fontSize: 14,
flex: 1,
lineHeight: 20,
opacity: 0.8,
marginLeft: 8,
},
clearButton: {
backgroundColor: 'transparent',
borderWidth: 2,
marginRight: 0,
marginLeft: 8,
flex: 0,
paddingHorizontal: 16,
},
// Modal Styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'flex-end',
},
modalContent: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
maxHeight: '85%',
minHeight: '70%', // Increased minimum height
flex: 1,
},
modalHeader: {
alignItems: 'center',
paddingTop: 12,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
dragHandle: {
width: 40,
height: 4,
borderRadius: 2,
marginBottom: 12,
},
modalTitle: {
fontSize: 20,
fontWeight: '700',
marginBottom: 4,
},
modalSubtitle: {
fontSize: 14,
textAlign: 'center',
},
searchSection: {
paddingHorizontal: 20,
paddingVertical: 16,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
},
searchIcon: {
marginRight: 12,
},
searchInput: {
flex: 1,
fontSize: 16,
paddingVertical: 0,
},
searchClearButton: {
padding: 4,
marginLeft: 8,
},
popularSection: {
paddingHorizontal: 20,
paddingBottom: 16,
},
searchResultsTitle: {
color: '#FFFFFF',
},
popularChips: {
paddingVertical: 2,
},
popularChip: {
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
marginRight: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
selectedChip: {
// Border color handled by inline styles
},
popularChipText: {
fontSize: 14,
fontWeight: '500',
},
selectedChipText: {
color: '#FFFFFF',
},
languagesSection: {
flex: 1,
paddingHorizontal: 20,
},
languageList: {
flex: 1,
},
languageItem: {
paddingVertical: 16,
paddingHorizontal: 16,
borderRadius: 12,
marginBottom: 4,
minHeight: 60,
},
selectedLanguageItem: {
backgroundColor: 'rgba(255,255,255,0.08)',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)',
},
languageContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
languageInfo: {
flex: 1,
marginRight: 12,
},
languageName: {
fontSize: 16,
fontWeight: '500',
marginBottom: 2,
},
selectedLanguageName: {
fontWeight: '600',
},
languageCode: {
fontSize: 12,
},
selectedLanguageCode: {
},
checkmarkContainer: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.1)',
alignItems: 'center',
justifyContent: 'center',
},
noResultsContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
},
noResultsText: {
fontSize: 16,
marginTop: 12,
textAlign: 'center',
},
clearSearchButton: {
marginTop: 16,
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
},
clearSearchButtonText: {
fontSize: 14,
fontWeight: '600',
},
modalFooter: {
flexDirection: 'row',
paddingHorizontal: 20,
paddingVertical: 20,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
gap: 12,
},
cancelButton: {
flex: 1,
paddingVertical: 14,
alignItems: 'center',
borderRadius: 12,
backgroundColor: 'rgba(255,255,255,0.1)',
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
},
doneButton: {
flex: 1,
paddingVertical: 14,
alignItems: 'center',
borderRadius: 12,
},
doneButtonText: {
fontSize: 16,
fontWeight: '700',
},
});

View file

@ -11,7 +11,7 @@ import {
Dimensions,
Linking
} from 'react-native';
import { toast, ToastPosition } from '@backpackapp-io/react-native-toast';
import { Toast } from 'toastify-react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
@ -152,9 +152,7 @@ const UpdateScreen: React.FC = () => {
// Also refresh GitHub section on mount (works in dev and prod)
try { github.refresh(); } catch {}
if (Platform.OS === 'android') {
try {
toast('Checking for updates…', { duration: 1200, position: ToastPosition.TOP });
} catch {}
try { Toast.info('Checking for updates…'); } catch {}
}
}, []);

View file

@ -0,0 +1,406 @@
import { localScraperCacheService, CachedScraperResult } from './localScraperCacheService';
import { supabaseGlobalCacheService, GlobalCachedScraperResult } from './supabaseGlobalCacheService';
import { logger } from '../utils/logger';
import { Stream } from '../types/streams';
export interface HybridCacheResult {
validResults: Array<CachedScraperResult | GlobalCachedScraperResult>;
expiredScrapers: string[];
allExpired: boolean;
source: 'local' | 'global' | 'hybrid';
}
export interface HybridCacheStats {
local: {
totalEntries: number;
totalSize: number;
oldestEntry: number | null;
newestEntry: number | null;
};
global: {
totalEntries: number;
totalSize: number;
oldestEntry: number | null;
newestEntry: number | null;
hitRate: number;
};
combined: {
totalEntries: number;
hitRate: number;
};
}
class HybridCacheService {
private static instance: HybridCacheService;
private readonly ENABLE_GLOBAL_CACHE = true; // Can be made configurable
private readonly FALLBACK_TO_LOCAL = true; // Fallback to local if global fails
private constructor() {}
public static getInstance(): HybridCacheService {
if (!HybridCacheService.instance) {
HybridCacheService.instance = new HybridCacheService();
}
return HybridCacheService.instance;
}
/**
* Get cached results with hybrid approach (global first, then local)
*/
async getCachedResults(
type: string,
tmdbId: string,
season?: number,
episode?: number,
userSettings?: { enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }
): Promise<HybridCacheResult> {
try {
// Filter function to check if scraper is enabled for current user
const isScraperEnabled = (scraperId: string): boolean => {
if (!userSettings?.enableLocalScrapers) return false;
if (userSettings?.enabledScrapers) {
return userSettings.enabledScrapers.has(scraperId);
}
// If no specific scraper settings, assume all are enabled if local scrapers are enabled
return true;
};
// Try global cache first if enabled
if (this.ENABLE_GLOBAL_CACHE) {
try {
const globalResults = await supabaseGlobalCacheService.getCachedResults(type, tmdbId, season, episode);
// Filter results based on user settings
const filteredGlobalResults = {
...globalResults,
validResults: globalResults.validResults.filter(result => isScraperEnabled(result.scraperId)),
expiredScrapers: globalResults.expiredScrapers.filter(scraperId => isScraperEnabled(scraperId))
};
if (filteredGlobalResults.validResults.length > 0) {
logger.log(`[HybridCache] Using global cache: ${filteredGlobalResults.validResults.length} results (filtered from ${globalResults.validResults.length})`);
return {
...filteredGlobalResults,
source: 'global'
};
}
} catch (error) {
logger.warn('[HybridCache] Global cache failed, falling back to local:', error);
}
}
// Fallback to local cache
if (this.FALLBACK_TO_LOCAL) {
const localResults = await localScraperCacheService.getCachedResults(type, tmdbId, season, episode);
// Filter results based on user settings
const filteredLocalResults = {
...localResults,
validResults: localResults.validResults.filter(result => isScraperEnabled(result.scraperId)),
expiredScrapers: localResults.expiredScrapers.filter(scraperId => isScraperEnabled(scraperId))
};
if (filteredLocalResults.validResults.length > 0) {
logger.log(`[HybridCache] Using local cache: ${filteredLocalResults.validResults.length} results (filtered from ${localResults.validResults.length})`);
return {
...filteredLocalResults,
source: 'local'
};
}
}
// No valid results found
return {
validResults: [],
expiredScrapers: [],
allExpired: true,
source: 'hybrid'
};
} catch (error) {
logger.error('[HybridCache] Error getting cached results:', error);
return {
validResults: [],
expiredScrapers: [],
allExpired: true,
source: 'hybrid'
};
}
}
/**
* Cache results in both local and global cache
*/
async cacheResults(
type: string,
tmdbId: string,
results: Array<{
scraperId: string;
scraperName: string;
streams: Stream[] | null;
error: Error | null;
}>,
season?: number,
episode?: number
): Promise<void> {
try {
// Cache in local storage first (fastest)
const localPromises = results.map(result =>
localScraperCacheService.cacheScraperResult(
type, tmdbId, result.scraperId, result.scraperName,
result.streams, result.error, season, episode
)
);
await Promise.all(localPromises);
// Cache in global storage (shared across users)
if (this.ENABLE_GLOBAL_CACHE) {
try {
await supabaseGlobalCacheService.cacheResults(type, tmdbId, results, season, episode);
logger.log(`[HybridCache] Cached ${results.length} results in both local and global cache`);
} catch (error) {
logger.warn('[HybridCache] Failed to cache in global storage:', error);
// Local cache succeeded, so we continue
}
}
} catch (error) {
logger.error('[HybridCache] Error caching results:', error);
}
}
/**
* Cache a single scraper result
*/
async cacheScraperResult(
type: string,
tmdbId: string,
scraperId: string,
scraperName: string,
streams: Stream[] | null,
error: Error | null,
season?: number,
episode?: number
): Promise<void> {
await this.cacheResults(type, tmdbId, [{
scraperId,
scraperName,
streams,
error
}], season, episode);
}
/**
* Get list of scrapers that need to be re-run
*/
async getScrapersToRerun(
type: string,
tmdbId: string,
availableScrapers: Array<{ id: string; name: string }>,
season?: number,
episode?: number,
userSettings?: { enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }
): Promise<string[]> {
const { validResults, expiredScrapers } = await this.getCachedResults(type, tmdbId, season, episode, userSettings);
const validScraperIds = new Set(validResults.map(r => r.scraperId));
const expiredScraperIds = new Set(expiredScrapers);
// Return scrapers that are either expired or not cached
const scrapersToRerun = availableScrapers
.filter(scraper =>
!validScraperIds.has(scraper.id) || expiredScraperIds.has(scraper.id)
)
.map(scraper => scraper.id);
logger.log(`[HybridCache] Scrapers to re-run: ${scrapersToRerun.join(', ')}`);
return scrapersToRerun;
}
/**
* Get all valid cached streams
*/
async getCachedStreams(
type: string,
tmdbId: string,
season?: number,
episode?: number,
userSettings?: { enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }
): Promise<Stream[]> {
const { validResults } = await this.getCachedResults(type, tmdbId, season, episode, userSettings);
// Flatten all valid streams
const allStreams: Stream[] = [];
for (const result of validResults) {
if (result.success && result.streams) {
allStreams.push(...result.streams);
}
}
return allStreams;
}
/**
* Invalidate cache for specific content
*/
async invalidateContent(
type: string,
tmdbId: string,
season?: number,
episode?: number
): Promise<void> {
try {
// Invalidate both local and global cache
const promises = [
localScraperCacheService.invalidateContent(type, tmdbId, season, episode)
];
if (this.ENABLE_GLOBAL_CACHE) {
promises.push(
supabaseGlobalCacheService.invalidateContent(type, tmdbId, season, episode)
);
}
await Promise.all(promises);
logger.log(`[HybridCache] Invalidated cache for ${type}:${tmdbId}`);
} catch (error) {
logger.error('[HybridCache] Error invalidating cache:', error);
}
}
/**
* Invalidate cache for specific scraper
*/
async invalidateScraper(scraperId: string): Promise<void> {
try {
// Invalidate both local and global cache
const promises = [
localScraperCacheService.invalidateScraper(scraperId)
];
if (this.ENABLE_GLOBAL_CACHE) {
promises.push(
supabaseGlobalCacheService.invalidateScraper(scraperId)
);
}
await Promise.all(promises);
logger.log(`[HybridCache] Invalidated cache for scraper ${scraperId}`);
} catch (error) {
logger.error('[HybridCache] Error invalidating scraper cache:', error);
}
}
/**
* Clear all cached results
*/
async clearAllCache(): Promise<void> {
try {
// Clear both local and global cache
const promises = [
localScraperCacheService.clearAllCache()
];
if (this.ENABLE_GLOBAL_CACHE) {
promises.push(
supabaseGlobalCacheService.clearAllCache()
);
}
await Promise.all(promises);
logger.log('[HybridCache] Cleared all cache (local and global)');
} catch (error) {
logger.error('[HybridCache] Error clearing cache:', error);
}
}
/**
* Get combined cache statistics
*/
async getCacheStats(): Promise<HybridCacheStats> {
try {
const [localStats, globalStats] = await Promise.all([
localScraperCacheService.getCacheStats(),
this.ENABLE_GLOBAL_CACHE ? supabaseGlobalCacheService.getCacheStats() : Promise.resolve({
totalEntries: 0,
totalSize: 0,
oldestEntry: null,
newestEntry: null,
hitRate: 0
})
]);
return {
local: localStats,
global: globalStats,
combined: {
totalEntries: localStats.totalEntries + globalStats.totalEntries,
hitRate: globalStats.hitRate // Global cache hit rate is more meaningful
}
};
} catch (error) {
logger.error('[HybridCache] Error getting cache stats:', error);
return {
local: { totalEntries: 0, totalSize: 0, oldestEntry: null, newestEntry: null },
global: { totalEntries: 0, totalSize: 0, oldestEntry: null, newestEntry: null, hitRate: 0 },
combined: { totalEntries: 0, hitRate: 0 }
};
}
}
/**
* Clean up old entries in both caches
*/
async cleanupOldEntries(): Promise<void> {
try {
const promises = [
localScraperCacheService.clearAllCache() // Local cache handles cleanup automatically
];
if (this.ENABLE_GLOBAL_CACHE) {
promises.push(
supabaseGlobalCacheService.cleanupOldEntries()
);
}
await Promise.all(promises);
logger.log('[HybridCache] Cleaned up old entries');
} catch (error) {
logger.error('[HybridCache] Error cleaning up old entries:', error);
}
}
/**
* Get cache configuration
*/
getConfig(): {
enableGlobalCache: boolean;
fallbackToLocal: boolean;
} {
return {
enableGlobalCache: this.ENABLE_GLOBAL_CACHE,
fallbackToLocal: this.FALLBACK_TO_LOCAL
};
}
/**
* Update cache configuration
*/
updateConfig(config: {
enableGlobalCache?: boolean;
fallbackToLocal?: boolean;
}): void {
if (config.enableGlobalCache !== undefined) {
(this as any).ENABLE_GLOBAL_CACHE = config.enableGlobalCache;
}
if (config.fallbackToLocal !== undefined) {
(this as any).FALLBACK_TO_LOCAL = config.fallbackToLocal;
}
logger.log('[HybridCache] Configuration updated:', this.getConfig());
}
}
export const hybridCacheService = HybridCacheService.getInstance();
export default hybridCacheService;

View file

@ -0,0 +1,425 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { logger } from '../utils/logger';
import { Stream } from '../types/streams';
export interface CachedScraperResult {
streams: Stream[];
timestamp: number;
success: boolean;
error?: string;
scraperId: string;
scraperName: string;
}
export interface CachedContentResult {
contentKey: string; // e.g., "movie:123" or "tv:123:1:2"
results: CachedScraperResult[];
timestamp: number;
ttl: number;
}
class LocalScraperCacheService {
private static instance: LocalScraperCacheService;
private readonly CACHE_KEY_PREFIX = 'local-scraper-cache';
private readonly DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes default TTL
private readonly MAX_CACHE_SIZE = 200; // Maximum number of cached content items
private readonly FAILED_RETRY_TTL_MS = 5 * 60 * 1000; // 5 minutes for failed scrapers
private readonly SUCCESS_TTL_MS = 60 * 60 * 1000; // 1 hour for successful scrapers
private constructor() {}
public static getInstance(): LocalScraperCacheService {
if (!LocalScraperCacheService.instance) {
LocalScraperCacheService.instance = new LocalScraperCacheService();
}
return LocalScraperCacheService.instance;
}
/**
* Generate cache key for content
*/
private getContentKey(type: string, tmdbId: string, season?: number, episode?: number): string {
if (season !== undefined && episode !== undefined) {
return `${type}:${tmdbId}:${season}:${episode}`;
}
return `${type}:${tmdbId}`;
}
/**
* Generate AsyncStorage key for cached content
*/
private getStorageKey(contentKey: string): string {
return `${this.CACHE_KEY_PREFIX}:${contentKey}`;
}
/**
* Check if cached result is still valid based on TTL
*/
private isCacheValid(timestamp: number, ttl: number): boolean {
return Date.now() - timestamp < ttl;
}
/**
* Get cached results for content, filtering out expired results
*/
async getCachedResults(
type: string,
tmdbId: string,
season?: number,
episode?: number
): Promise<{
validResults: CachedScraperResult[];
expiredScrapers: string[];
allExpired: boolean;
}> {
try {
const contentKey = this.getContentKey(type, tmdbId, season, episode);
const storageKey = this.getStorageKey(contentKey);
const cachedData = await AsyncStorage.getItem(storageKey);
if (!cachedData) {
return {
validResults: [],
expiredScrapers: [],
allExpired: true
};
}
const parsed: CachedContentResult = JSON.parse(cachedData);
// Check if the entire cache entry is expired
if (!this.isCacheValid(parsed.timestamp, parsed.ttl)) {
// Remove expired entry
await AsyncStorage.removeItem(storageKey);
return {
validResults: [],
expiredScrapers: parsed.results.map(r => r.scraperId),
allExpired: true
};
}
// Filter valid results and identify expired scrapers
const validResults: CachedScraperResult[] = [];
const expiredScrapers: string[] = [];
for (const result of parsed.results) {
// Use different TTL based on success/failure
const ttl = result.success ? this.SUCCESS_TTL_MS : this.FAILED_RETRY_TTL_MS;
if (this.isCacheValid(result.timestamp, ttl)) {
validResults.push(result);
} else {
expiredScrapers.push(result.scraperId);
}
}
logger.log(`[LocalScraperCache] Retrieved ${validResults.length} valid results, ${expiredScrapers.length} expired scrapers for ${contentKey}`);
return {
validResults,
expiredScrapers,
allExpired: validResults.length === 0
};
} catch (error) {
logger.error('[LocalScraperCache] Error getting cached results:', error);
return {
validResults: [],
expiredScrapers: [],
allExpired: true
};
}
}
/**
* Cache results for specific scrapers
*/
async cacheResults(
type: string,
tmdbId: string,
results: CachedScraperResult[],
season?: number,
episode?: number
): Promise<void> {
try {
const contentKey = this.getContentKey(type, tmdbId, season, episode);
const storageKey = this.getStorageKey(contentKey);
// Get existing cached data
const existingData = await AsyncStorage.getItem(storageKey);
let cachedContent: CachedContentResult;
if (existingData) {
cachedContent = JSON.parse(existingData);
// Update existing results or add new ones
for (const newResult of results) {
const existingIndex = cachedContent.results.findIndex(r => r.scraperId === newResult.scraperId);
if (existingIndex >= 0) {
// Update existing result
cachedContent.results[existingIndex] = newResult;
} else {
// Add new result
cachedContent.results.push(newResult);
}
}
} else {
// Create new cache entry
cachedContent = {
contentKey,
results,
timestamp: Date.now(),
ttl: this.DEFAULT_TTL_MS
};
}
// Update timestamp
cachedContent.timestamp = Date.now();
// Store updated cache
await AsyncStorage.setItem(storageKey, JSON.stringify(cachedContent));
// Clean up old cache entries if we exceed the limit
await this.cleanupOldEntries();
logger.log(`[LocalScraperCache] Cached ${results.length} results for ${contentKey}`);
} catch (error) {
logger.error('[LocalScraperCache] Error caching results:', error);
}
}
/**
* Cache a single scraper result
*/
async cacheScraperResult(
type: string,
tmdbId: string,
scraperId: string,
scraperName: string,
streams: Stream[] | null,
error: Error | null,
season?: number,
episode?: number
): Promise<void> {
const result: CachedScraperResult = {
streams: streams || [],
timestamp: Date.now(),
success: !error && streams !== null,
error: error?.message,
scraperId,
scraperName
};
await this.cacheResults(type, tmdbId, [result], season, episode);
}
/**
* Get list of scrapers that need to be re-run (expired or failed)
*/
async getScrapersToRerun(
type: string,
tmdbId: string,
availableScrapers: Array<{ id: string; name: string }>,
season?: number,
episode?: number
): Promise<string[]> {
const { validResults, expiredScrapers } = await this.getCachedResults(type, tmdbId, season, episode);
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
const scrapersToRerun = availableScrapers
.filter(scraper =>
!validScraperIds.has(scraper.id) || expiredScraperIds.has(scraper.id)
)
.map(scraper => scraper.id);
logger.log(`[LocalScraperCache] Scrapers to re-run: ${scrapersToRerun.join(', ')}`);
return scrapersToRerun;
}
/**
* Get all valid cached streams for content
*/
async getCachedStreams(
type: string,
tmdbId: string,
season?: number,
episode?: number
): Promise<Stream[]> {
const { validResults } = await this.getCachedResults(type, tmdbId, season, episode);
// Flatten all valid streams
const allStreams: Stream[] = [];
for (const result of validResults) {
if (result.success && result.streams) {
allStreams.push(...result.streams);
}
}
return allStreams;
}
/**
* Invalidate cache for specific content
*/
async invalidateContent(
type: string,
tmdbId: string,
season?: number,
episode?: number
): Promise<void> {
try {
const contentKey = this.getContentKey(type, tmdbId, season, episode);
const storageKey = this.getStorageKey(contentKey);
await AsyncStorage.removeItem(storageKey);
logger.log(`[LocalScraperCache] Invalidated cache for ${contentKey}`);
} catch (error) {
logger.error('[LocalScraperCache] Error invalidating cache:', error);
}
}
/**
* Invalidate cache for specific scraper across all content
*/
async invalidateScraper(scraperId: string): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter(key => key.startsWith(this.CACHE_KEY_PREFIX));
for (const key of cacheKeys) {
const cachedData = await AsyncStorage.getItem(key);
if (cachedData) {
const parsed: CachedContentResult = JSON.parse(cachedData);
// Remove results from this scraper
parsed.results = parsed.results.filter(r => r.scraperId !== scraperId);
if (parsed.results.length === 0) {
// Remove entire cache entry if no results left
await AsyncStorage.removeItem(key);
} else {
// Update cache with remaining results
await AsyncStorage.setItem(key, JSON.stringify(parsed));
}
}
}
logger.log(`[LocalScraperCache] Invalidated cache for scraper ${scraperId}`);
} catch (error) {
logger.error('[LocalScraperCache] Error invalidating scraper cache:', error);
}
}
/**
* Clear all cached results
*/
async clearAllCache(): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter(key => key.startsWith(this.CACHE_KEY_PREFIX));
await AsyncStorage.multiRemove(cacheKeys);
logger.log(`[LocalScraperCache] Cleared ${cacheKeys.length} cache entries`);
} catch (error) {
logger.error('[LocalScraperCache] Error clearing cache:', error);
}
}
/**
* Clean up old cache entries to stay within size limit
*/
private async cleanupOldEntries(): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter(key => key.startsWith(this.CACHE_KEY_PREFIX));
if (cacheKeys.length <= this.MAX_CACHE_SIZE) {
return; // No cleanup needed
}
// Get all cache entries with their timestamps
const entriesWithTimestamps = await Promise.all(
cacheKeys.map(async (key) => {
const data = await AsyncStorage.getItem(key);
if (data) {
const parsed: CachedContentResult = JSON.parse(data);
return { key, timestamp: parsed.timestamp };
}
return { key, timestamp: 0 };
})
);
// Sort by timestamp (oldest first)
entriesWithTimestamps.sort((a, b) => a.timestamp - b.timestamp);
// Remove oldest entries
const entriesToRemove = entriesWithTimestamps.slice(0, cacheKeys.length - this.MAX_CACHE_SIZE);
const keysToRemove = entriesToRemove.map(entry => entry.key);
if (keysToRemove.length > 0) {
await AsyncStorage.multiRemove(keysToRemove);
logger.log(`[LocalScraperCache] Cleaned up ${keysToRemove.length} old cache entries`);
}
} catch (error) {
logger.error('[LocalScraperCache] Error cleaning up cache:', error);
}
}
/**
* Get cache statistics
*/
async getCacheStats(): Promise<{
totalEntries: number;
totalSize: number;
oldestEntry: number | null;
newestEntry: number | null;
}> {
try {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter(key => key.startsWith(this.CACHE_KEY_PREFIX));
let totalSize = 0;
let oldestTimestamp: number | null = null;
let newestTimestamp: number | null = null;
for (const key of cacheKeys) {
const data = await AsyncStorage.getItem(key);
if (data) {
totalSize += data.length;
const parsed: CachedContentResult = JSON.parse(data);
if (oldestTimestamp === null || parsed.timestamp < oldestTimestamp) {
oldestTimestamp = parsed.timestamp;
}
if (newestTimestamp === null || parsed.timestamp > newestTimestamp) {
newestTimestamp = parsed.timestamp;
}
}
}
return {
totalEntries: cacheKeys.length,
totalSize,
oldestEntry: oldestTimestamp,
newestEntry: newestTimestamp
};
} catch (error) {
logger.error('[LocalScraperCache] Error getting cache stats:', error);
return {
totalEntries: 0,
totalSize: 0,
oldestEntry: null,
newestEntry: null
};
}
}
}
export const localScraperCacheService = LocalScraperCacheService.getInstance();
export default localScraperCacheService;

View file

@ -4,6 +4,8 @@ import { Platform } from 'react-native';
import { logger } from '../utils/logger';
import { Stream } from '../types/streams';
import { cacheService } from './cacheService';
import { localScraperCacheService } from './localScraperCacheService';
import { hybridCacheService } from './hybridCacheService';
import CryptoJS from 'crypto-js';
// Types for local scrapers
@ -34,6 +36,7 @@ export interface ScraperInfo {
supportedFormats?: string[];
repositoryId?: string; // Which repository this scraper came from
supportsExternalPlayer?: boolean; // Whether this scraper supports external players
limited?: boolean; // Whether this scraper has limited functionality
}
export interface RepositoryInfo {
@ -81,6 +84,8 @@ class LocalScraperService {
private autoRefreshCompleted: boolean = false;
private isRefreshing: boolean = false;
private scraperSettingsCache: Record<string, any> | null = null;
// Single-flight map to prevent duplicate concurrent runs per scraper+title
private inFlightByKey: Map<string, Promise<LocalScraperResult[]>> = new Map();
private constructor() {
this.initialize();
@ -857,7 +862,7 @@ class LocalScraperService {
}
}
// Execute scrapers for streams
// Execute scrapers for streams with caching
async getStreams(type: string, tmdbId: string, season?: number, episode?: number, callback?: ScraperCallback): Promise<void> {
await this.ensureInitialized();
@ -874,23 +879,61 @@ class LocalScraperService {
logger.log('[LocalScraperService] No enabled scrapers found for type:', type);
return;
}
// Get current user settings for enabled scrapers
const userSettings = await this.getUserScraperSettings();
// Check cache for existing results (hybrid: global first, then local)
const { validResults, expiredScrapers, allExpired, source } = await hybridCacheService.getCachedResults(type, tmdbId, season, episode, userSettings);
logger.log('[LocalScraperService] Executing', enabledScrapers.length, 'scrapers for', type, tmdbId);
// Execute each scraper
for (const scraper of enabledScrapers) {
this.executeScraper(scraper, type, tmdbId, season, episode, callback);
// Immediately return cached results for valid scrapers
if (validResults.length > 0) {
logger.log(`[LocalScraperService] Returning ${validResults.length} cached results for ${type}:${tmdbId} (source: ${source})`);
for (const cachedResult of validResults) {
if (cachedResult.success && cachedResult.streams.length > 0) {
// Streams are already in the correct format, just pass them through
if (callback) {
callback(cachedResult.streams, cachedResult.scraperId, cachedResult.scraperName, null);
}
} else if (callback) {
// Return error for failed cached results
const error = cachedResult.error ? new Error(cachedResult.error) : new Error('Scraper failed');
callback(null, cachedResult.scraperId, cachedResult.scraperName, error);
}
}
}
// Determine which scrapers need to be re-run
const scrapersToRerun = enabledScrapers.filter(scraper =>
expiredScrapers.includes(scraper.id) || !validResults.some(r => r.scraperId === scraper.id)
);
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}`);
// Generate a lightweight request id for tracing
const requestId = `rs_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
// Execute only scrapers that need to be re-run
for (const scraper of scrapersToRerun) {
this.executeScraperWithCaching(scraper, type, tmdbId, season, episode, callback, requestId);
}
}
// Execute individual scraper
private async executeScraper(
// Execute individual scraper with caching
private async executeScraperWithCaching(
scraper: ScraperInfo,
type: string,
tmdbId: string,
season?: number,
episode?: number,
callback?: ScraperCallback
callback?: ScraperCallback,
requestId?: string
): Promise<void> {
try {
const code = this.scraperCode.get(scraper.id);
@ -898,38 +941,90 @@ class LocalScraperService {
throw new Error(`No code found for scraper ${scraper.id}`);
}
// Skip verbose logging to reduce CPU load
// Load per-scraper settings
const scraperSettings = await this.getScraperSettings(scraper.id);
// Create a sandboxed execution environment
const results = await this.executeSandboxed(code, {
tmdbId,
mediaType: type,
season,
episode,
scraperId: scraper.id,
settings: scraperSettings
});
// Build single-flight key
const flightKey = `${scraper.id}|${type}|${tmdbId}|${season ?? ''}|${episode ?? ''}`;
// Create a sandboxed execution environment with single-flight coalescing
let promise: Promise<LocalScraperResult[]>;
if (this.inFlightByKey.has(flightKey)) {
promise = this.inFlightByKey.get(flightKey)!;
} else {
promise = this.executeSandboxed(code, {
tmdbId,
mediaType: type,
season,
episode,
scraperId: scraper.id,
settings: scraperSettings,
requestId
});
this.inFlightByKey.set(flightKey, promise);
// Clean up after settle; guard against races
promise.finally(() => {
const current = this.inFlightByKey.get(flightKey);
if (current === promise) this.inFlightByKey.delete(flightKey);
}).catch(() => {});
}
const results = await promise;
// Convert results to Nuvio Stream format
const streams = this.convertToStreams(results, scraper);
// Cache the successful result (hybrid: both local and global)
await hybridCacheService.cacheScraperResult(
type,
tmdbId,
scraper.id,
scraper.name,
streams,
null,
season,
episode
);
if (callback) {
callback(streams, scraper.id, scraper.name, null);
}
// Skip verbose logging to reduce CPU load
} catch (error) {
logger.error('[LocalScraperService] Scraper', scraper.name, 'failed:', error);
// Cache the failed result (hybrid: both local and global)
await hybridCacheService.cacheScraperResult(
type,
tmdbId,
scraper.id,
scraper.name,
null,
error as Error,
season,
episode
);
if (callback) {
callback(null, scraper.id, scraper.name, error as Error);
}
}
}
// Execute individual scraper (legacy method - kept for compatibility)
private async executeScraper(
scraper: ScraperInfo,
type: string,
tmdbId: string,
season?: number,
episode?: number,
callback?: ScraperCallback,
requestId?: string
): Promise<void> {
// Delegate to the caching version
return this.executeScraperWithCaching(scraper, type, tmdbId, season, episode, callback, requestId);
}
// Execute scraper code in sandboxed environment
private async executeSandboxed(code: string, params: any): Promise<LocalScraperResult[]> {
// This is a simplified sandbox - in production, you'd want more security
@ -1056,7 +1151,7 @@ class LocalScraperService {
...options.headers
},
data: options.body,
timeout: 30000,
timeout: 60000,
validateStatus: () => true // Don't throw on HTTP error status codes
};
@ -1262,6 +1357,84 @@ class LocalScraperService {
await this.ensureInitialized();
return Array.from(this.installedScrapers.values()).some(scraper => scraper.enabled);
}
// Get current user scraper settings for cache filtering
private async getUserScraperSettings(): Promise<{ enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }> {
return this.getUserScraperSettingsWithOverride();
}
// Get user scraper settings (can be overridden for testing or external calls)
async getUserScraperSettingsWithOverride(overrideSettings?: { enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }): Promise<{ enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }> {
try {
// If override settings are provided, use them
if (overrideSettings) {
return {
enableLocalScrapers: overrideSettings.enableLocalScrapers,
enabledScrapers: overrideSettings.enabledScrapers
};
}
// Get user settings from AsyncStorage
const settingsData = await AsyncStorage.getItem('app_settings');
const settings = settingsData ? JSON.parse(settingsData) : {};
// Get enabled scrapers based on current user settings
const enabledScrapers = new Set<string>();
const installedScrapers = Array.from(this.installedScrapers.values());
for (const scraper of installedScrapers) {
if (scraper.enabled && settings.enableLocalScrapers) {
enabledScrapers.add(scraper.id);
}
}
return {
enableLocalScrapers: settings.enableLocalScrapers,
enabledScrapers: enabledScrapers.size > 0 ? enabledScrapers : undefined
};
} catch (error) {
logger.error('[LocalScraperService] Error getting user scraper settings:', error);
return { enableLocalScrapers: false };
}
}
// Cache management methods (hybrid: local + global)
async clearScraperCache(): Promise<void> {
await hybridCacheService.clearAllCache();
logger.log('[LocalScraperService] Cleared all scraper cache (local + global)');
}
async invalidateScraperCache(scraperId: string): Promise<void> {
await hybridCacheService.invalidateScraper(scraperId);
logger.log('[LocalScraperService] Invalidated cache for scraper:', scraperId);
}
async invalidateContentCache(type: string, tmdbId: string, season?: number, episode?: number): Promise<void> {
await hybridCacheService.invalidateContent(type, tmdbId, season, episode);
logger.log('[LocalScraperService] Invalidated cache for content:', `${type}:${tmdbId}`);
}
async getCacheStats(): Promise<{
local: {
totalEntries: number;
totalSize: number;
oldestEntry: number | null;
newestEntry: number | null;
};
global: {
totalEntries: number;
totalSize: number;
oldestEntry: number | null;
newestEntry: number | null;
hitRate: number;
};
combined: {
totalEntries: number;
hitRate: number;
};
}> {
return await hybridCacheService.getCacheStats();
}
}
export const localScraperService = LocalScraperService.getInstance();

View file

@ -1180,7 +1180,7 @@ class StremioService {
try {
// Increase timeout for debrid services
const timeout = addon.id.toLowerCase().includes('torrentio') ? 30000 : 10000;
const timeout = addon.id.toLowerCase().includes('torrentio') ? 60000 : 10000;
const response = await this.retryRequest(async () => {
logger.log(`Making request to ${url} with timeout ${timeout}ms`);

View file

@ -0,0 +1,453 @@
import { supabase } from './supabaseClient';
import { logger } from '../utils/logger';
import { Stream } from '../types/streams';
export interface GlobalCachedScraperResult {
streams: Stream[];
timestamp: number;
success: boolean;
error?: string;
scraperId: string;
scraperName: string;
contentKey: string; // e.g., "movie:123" or "tv:123:1:2"
}
export interface GlobalCacheStats {
totalEntries: number;
totalSize: number;
oldestEntry: number | null;
newestEntry: number | null;
hitRate: number;
}
class SupabaseGlobalCacheService {
private static instance: SupabaseGlobalCacheService;
private readonly TABLE_NAME = 'scraper_cache';
private readonly DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes default TTL
private readonly FAILED_RETRY_TTL_MS = 5 * 60 * 1000; // 5 minutes for failed scrapers
private readonly SUCCESS_TTL_MS = 60 * 60 * 1000; // 1 hour for successful scrapers
private readonly MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days max age
private readonly BATCH_SIZE = 50; // Batch size for operations
// Cache hit/miss tracking
private cacheHits = 0;
private cacheMisses = 0;
private constructor() {}
public static getInstance(): SupabaseGlobalCacheService {
if (!SupabaseGlobalCacheService.instance) {
SupabaseGlobalCacheService.instance = new SupabaseGlobalCacheService();
}
return SupabaseGlobalCacheService.instance;
}
/**
* Generate cache key for content
*/
private getContentKey(type: string, tmdbId: string, season?: number, episode?: number): string {
if (season !== undefined && episode !== undefined) {
return `${type}:${tmdbId}:${season}:${episode}`;
}
return `${type}:${tmdbId}`;
}
/**
* Generate unique key for scraper result
*/
private getScraperKey(contentKey: string, scraperId: string): string {
return `${contentKey}:${scraperId}`;
}
/**
* Check if cached result is still valid based on TTL
*/
private isCacheValid(timestamp: number, success: boolean): boolean {
const ttl = success ? this.SUCCESS_TTL_MS : this.FAILED_RETRY_TTL_MS;
return Date.now() - timestamp < ttl;
}
/**
* Get cached results for content from global cache
*/
async getCachedResults(
type: string,
tmdbId: string,
season?: number,
episode?: number
): Promise<{
validResults: GlobalCachedScraperResult[];
expiredScrapers: string[];
allExpired: boolean;
}> {
try {
const contentKey = this.getContentKey(type, tmdbId, season, episode);
const { data, error } = await supabase
.from(this.TABLE_NAME)
.select('*')
.eq('content_key', contentKey)
.gte('created_at', new Date(Date.now() - this.MAX_CACHE_AGE_MS).toISOString());
if (error) {
logger.error('[GlobalCache] Error fetching cached results:', error);
this.cacheMisses++;
return {
validResults: [],
expiredScrapers: [],
allExpired: true
};
}
if (!data || data.length === 0) {
this.cacheMisses++;
return {
validResults: [],
expiredScrapers: [],
allExpired: true
};
}
// Filter valid results and identify expired scrapers
const validResults: GlobalCachedScraperResult[] = [];
const expiredScrapers: string[] = [];
for (const row of data) {
const result: GlobalCachedScraperResult = {
streams: row.streams || [],
timestamp: new Date(row.created_at).getTime(),
success: row.success,
error: row.error,
scraperId: row.scraper_id,
scraperName: row.scraper_name,
contentKey: row.content_key
};
if (this.isCacheValid(result.timestamp, result.success)) {
validResults.push(result);
} else {
expiredScrapers.push(result.scraperId);
}
}
// Track cache hits
if (validResults.length > 0) {
this.cacheHits++;
} else {
this.cacheMisses++;
}
logger.log(`[GlobalCache] Retrieved ${validResults.length} valid results, ${expiredScrapers.length} expired scrapers for ${contentKey}`);
return {
validResults,
expiredScrapers,
allExpired: validResults.length === 0
};
} catch (error) {
logger.error('[GlobalCache] Error getting cached results:', error);
this.cacheMisses++;
return {
validResults: [],
expiredScrapers: [],
allExpired: true
};
}
}
/**
* Cache results for specific scrapers in global cache
*/
async cacheResults(
type: string,
tmdbId: string,
results: Array<{
scraperId: string;
scraperName: string;
streams: Stream[] | null;
error: Error | null;
}>,
season?: number,
episode?: number
): Promise<void> {
try {
const contentKey = this.getContentKey(type, tmdbId, season, episode);
const now = new Date().toISOString();
// Prepare batch insert data
const insertData = results.map(result => ({
scraper_key: this.getScraperKey(contentKey, result.scraperId),
content_key: contentKey,
scraper_id: result.scraperId,
scraper_name: result.scraperName,
streams: result.streams || [],
success: !result.error && result.streams !== null,
error: result.error?.message || null,
created_at: now,
updated_at: now
}));
// Use upsert to handle duplicates
const { error } = await supabase
.from(this.TABLE_NAME)
.upsert(insertData, {
onConflict: 'scraper_key',
ignoreDuplicates: false
});
if (error) {
logger.error('[GlobalCache] Error caching results:', error);
} else {
logger.log(`[GlobalCache] Cached ${results.length} results for ${contentKey}`);
}
} catch (error) {
logger.error('[GlobalCache] Error caching results:', error);
}
}
/**
* Cache a single scraper result
*/
async cacheScraperResult(
type: string,
tmdbId: string,
scraperId: string,
scraperName: string,
streams: Stream[] | null,
error: Error | null,
season?: number,
episode?: number
): Promise<void> {
await this.cacheResults(type, tmdbId, [{
scraperId,
scraperName,
streams,
error
}], season, episode);
}
/**
* Get list of scrapers that need to be re-run (expired or not cached globally)
*/
async getScrapersToRerun(
type: string,
tmdbId: string,
availableScrapers: Array<{ id: string; name: string }>,
season?: number,
episode?: number
): Promise<string[]> {
const { validResults, expiredScrapers } = await this.getCachedResults(type, tmdbId, season, episode);
const validScraperIds = new Set(validResults.map(r => r.scraperId));
const expiredScraperIds = new Set(expiredScrapers);
// Return scrapers that are either expired or not cached globally
const scrapersToRerun = availableScrapers
.filter(scraper =>
!validScraperIds.has(scraper.id) || expiredScraperIds.has(scraper.id)
)
.map(scraper => scraper.id);
logger.log(`[GlobalCache] Scrapers to re-run: ${scrapersToRerun.join(', ')}`);
return scrapersToRerun;
}
/**
* Get all valid cached streams for content from global cache
*/
async getCachedStreams(
type: string,
tmdbId: string,
season?: number,
episode?: number
): Promise<Stream[]> {
const { validResults } = await this.getCachedResults(type, tmdbId, season, episode);
// Flatten all valid streams
const allStreams: Stream[] = [];
for (const result of validResults) {
if (result.success && result.streams) {
allStreams.push(...result.streams);
}
}
return allStreams;
}
/**
* Invalidate cache for specific content globally
*/
async invalidateContent(
type: string,
tmdbId: string,
season?: number,
episode?: number
): Promise<void> {
try {
const contentKey = this.getContentKey(type, tmdbId, season, episode);
const { error } = await supabase
.from(this.TABLE_NAME)
.delete()
.eq('content_key', contentKey);
if (error) {
logger.error('[GlobalCache] Error invalidating cache:', error);
} else {
logger.log(`[GlobalCache] Invalidated global cache for ${contentKey}`);
}
} catch (error) {
logger.error('[GlobalCache] Error invalidating cache:', error);
}
}
/**
* Invalidate cache for specific scraper across all content globally
*/
async invalidateScraper(scraperId: string): Promise<void> {
try {
const { error } = await supabase
.from(this.TABLE_NAME)
.delete()
.eq('scraper_id', scraperId);
if (error) {
logger.error('[GlobalCache] Error invalidating scraper cache:', error);
} else {
logger.log(`[GlobalCache] Invalidated global cache for scraper ${scraperId}`);
}
} catch (error) {
logger.error('[GlobalCache] Error invalidating scraper cache:', error);
}
}
/**
* Clear all cached results globally (admin function)
*/
async clearAllCache(): Promise<void> {
try {
const { error } = await supabase
.from(this.TABLE_NAME)
.delete()
.neq('id', 0); // Delete all rows
if (error) {
logger.error('[GlobalCache] Error clearing cache:', error);
} else {
logger.log('[GlobalCache] Cleared all global cache');
}
} catch (error) {
logger.error('[GlobalCache] Error clearing cache:', error);
}
}
/**
* Clean up old cache entries (older than MAX_CACHE_AGE_MS)
*/
async cleanupOldEntries(): Promise<void> {
try {
const cutoffDate = new Date(Date.now() - this.MAX_CACHE_AGE_MS).toISOString();
const { error } = await supabase
.from(this.TABLE_NAME)
.delete()
.lt('created_at', cutoffDate);
if (error) {
logger.error('[GlobalCache] Error cleaning up old entries:', error);
} else {
logger.log('[GlobalCache] Cleaned up old cache entries');
}
} catch (error) {
logger.error('[GlobalCache] Error cleaning up old entries:', error);
}
}
/**
* Get global cache statistics
*/
async getCacheStats(): Promise<GlobalCacheStats> {
try {
// Get total count
const { count: totalEntries, error: countError } = await supabase
.from(this.TABLE_NAME)
.select('*', { count: 'exact', head: true });
if (countError) {
logger.error('[GlobalCache] Error getting cache stats:', countError);
return {
totalEntries: 0,
totalSize: 0,
oldestEntry: null,
newestEntry: null,
hitRate: 0
};
}
// Get oldest and newest entries
const { data: oldestData } = await supabase
.from(this.TABLE_NAME)
.select('created_at')
.order('created_at', { ascending: true })
.limit(1);
const { data: newestData } = await supabase
.from(this.TABLE_NAME)
.select('created_at')
.order('created_at', { ascending: false })
.limit(1);
const oldestEntry = oldestData?.[0] ? new Date(oldestData[0].created_at).getTime() : null;
const newestEntry = newestData?.[0] ? new Date(newestData[0].created_at).getTime() : null;
// Calculate hit rate
const totalRequests = this.cacheHits + this.cacheMisses;
const hitRate = totalRequests > 0 ? (this.cacheHits / totalRequests) * 100 : 0;
return {
totalEntries: totalEntries || 0,
totalSize: 0, // Size calculation would require additional queries
oldestEntry,
newestEntry,
hitRate
};
} catch (error) {
logger.error('[GlobalCache] Error getting cache stats:', error);
return {
totalEntries: 0,
totalSize: 0,
oldestEntry: null,
newestEntry: null,
hitRate: 0
};
}
}
/**
* Reset cache hit/miss statistics
*/
resetStats(): void {
this.cacheHits = 0;
this.cacheMisses = 0;
}
/**
* Get cache hit/miss statistics
*/
getHitMissStats(): { hits: number; misses: number; hitRate: number } {
const totalRequests = this.cacheHits + this.cacheMisses;
const hitRate = totalRequests > 0 ? (this.cacheHits / totalRequests) * 100 : 0;
return {
hits: this.cacheHits,
misses: this.cacheMisses,
hitRate
};
}
}
export const supabaseGlobalCacheService = SupabaseGlobalCacheService.getInstance();
export default supabaseGlobalCacheService;

View file

@ -160,12 +160,12 @@ export class TMDBService {
/**
* Get TV show details by TMDB ID
*/
async getTVShowDetails(tmdbId: number): Promise<TMDBShow | null> {
async getTVShowDetails(tmdbId: number, language: string = 'en'): Promise<TMDBShow | null> {
try {
const response = await axios.get(`${BASE_URL}/tv/${tmdbId}`, {
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
language,
append_to_response: 'external_ids,credits,keywords' // Append external IDs, cast/crew, and keywords for AI context
}),
});
@ -237,12 +237,12 @@ export class TMDBService {
/**
* Get season details including all episodes with IMDb ratings
*/
async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string): Promise<TMDBSeason | null> {
async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string, language: string = 'en-US'): Promise<TMDBSeason | null> {
try {
const response = await axios.get(`${BASE_URL}/tv/${tmdbId}/season/${seasonNumber}`, {
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
language,
}),
});
@ -292,7 +292,8 @@ export class TMDBService {
async getEpisodeDetails(
tmdbId: number,
seasonNumber: number,
episodeNumber: number
episodeNumber: number,
language: string = 'en-US'
): Promise<TMDBEpisode | null> {
try {
const response = await axios.get(
@ -300,7 +301,7 @@ export class TMDBService {
{
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
language,
append_to_response: 'credits' // Include guest stars and crew for episode context
}),
}
@ -546,14 +547,14 @@ export class TMDBService {
}
}
async getRecommendations(type: 'movie' | 'tv', tmdbId: string): Promise<any[]> {
async getRecommendations(type: 'movie' | 'tv', tmdbId: string, language: string = 'en-US'): Promise<any[]> {
if (!this.apiKey) {
return [];
}
try {
const response = await axios.get(`${BASE_URL}/${type}/${tmdbId}/recommendations`, {
headers: await this.getHeaders(),
params: await this.getParams({ language: 'en-US' })
params: await this.getParams({ language })
});
return response.data.results || [];
} catch (error) {
@ -581,12 +582,12 @@ export class TMDBService {
/**
* Get movie details by TMDB ID
*/
async getMovieDetails(movieId: string): Promise<any> {
async getMovieDetails(movieId: string, language: string = 'en'): Promise<any> {
try {
const response = await axios.get(`${BASE_URL}/movie/${movieId}`, {
headers: await this.getHeaders(),
params: await this.getParams({
language: 'en-US',
language,
append_to_response: 'external_ids,credits,keywords,release_dates' // Include release dates for accurate availability
}),
});