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

@ -185,10 +185,29 @@ const KSPlayerCore: React.FC = () => {
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');

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,6 +395,10 @@ 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>
@ -358,7 +409,11 @@ const LibraryScreen = () => {
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
@ -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

@ -779,7 +779,7 @@ 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';
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'];
@ -1819,6 +1822,10 @@ const PluginsScreen: React.FC = () => {
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,13 +462,51 @@ 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}
>
@ -461,11 +520,17 @@ const SearchScreen = () => {
contentFit="cover"
transition={300}
/>
<View style={styles.itemTypeContainer}>
<Text style={[styles.itemTypeText, { color: currentTheme.colors.white }]}>
{item.type === 'movie' ? 'MOVIE' : 'SERIES'}
</Text>
{/* 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" />
@ -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 ? (
@ -634,7 +707,6 @@ const SearchScreen = () => {
showsVerticalScrollIndicator={false}
>
{!query.trim() && renderRecentSearches()}
{movieResults.length > 0 && (
<Animated.View
style={styles.carouselContainer}
@ -645,15 +717,25 @@ const SearchScreen = () => {
</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}
@ -664,18 +746,71 @@ const SearchScreen = () => {
</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,11 +729,71 @@ 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}`);
// Prevent duplicate calls if already loading
if (isLoadingStreamsRef.current) {
if (__DEV__) console.log('[StreamsScreen] checkProviders() skipping - already loading');
return;
}
isLoadingStreamsRef.current = true;
try {
// Check for Stremio addons
const hasStremioProviders = await stremioService.hasStreamProviders();
if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders);
@ -746,9 +802,42 @@ export const StreamsScreen = () => {
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}`);
// 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;
@ -756,12 +845,68 @@ export const StreamsScreen = () => {
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}`);
@ -809,6 +954,9 @@ export const StreamsScreen = () => {
}
}
}
} finally {
isLoadingStreamsRef.current = false;
}
};
checkProviders();

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,
@ -290,10 +293,22 @@ const TMDBSettingsScreen = () => {
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>
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Enhance your content metadata with TMDb data for better details and information.
</Text>
<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>
<Switch
value={settings.enrichMetadataWithTMDB}
@ -303,12 +318,66 @@ const TMDBSettingsScreen = () => {
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.
{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
@ -322,27 +391,24 @@ const TMDBSettingsScreen = () => {
{useCustomKey && (
<>
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.divider} />
{/* API Key Status */}
<View style={styles.statusRow}>
<MaterialIcons
name={isKeySet ? "check-circle" : "error-outline"}
size={28}
size={20}
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 style={[styles.statusText, {
color: isKeySet ? currentTheme.colors.success : currentTheme.colors.warning
}]}>
{isKeySet ? "Custom 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>
{/* API Key Input */}
<View style={styles.apiKeyContainer}>
<View style={styles.inputContainer}>
<TextInput
ref={apiKeyInputRef}
@ -380,7 +446,7 @@ const TMDBSettingsScreen = () => {
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={saveApiKey}
>
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save API Key</Text>
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save</Text>
</TouchableOpacity>
{isKeySet && (
@ -400,7 +466,7 @@ const TMDBSettingsScreen = () => {
]}>
<MaterialIcons
name={testResult.success ? "check-circle" : "error"}
size={18}
size={16}
color={testResult.success ? currentTheme.colors.success : currentTheme.colors.error}
style={styles.resultIcon}
/>
@ -423,26 +489,229 @@ const TMDBSettingsScreen = () => {
</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.
</Text>
</View>
</>
)}
{!useCustomKey && (
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
<View style={styles.infoContainer}>
<MaterialIcons name="info-outline" size={18} color={currentTheme.colors.primary} />
<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.
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();
@ -875,22 +880,60 @@ class LocalScraperService {
return;
}
logger.log('[LocalScraperService] Executing', enabledScrapers.length, 'scrapers for', type, tmdbId);
// Get current user settings for enabled scrapers
const userSettings = await this.getUserScraperSettings();
// Execute each scraper
for (const scraper of enabledScrapers) {
this.executeScraper(scraper, type, tmdbId, season, episode, callback);
// Check cache for existing results (hybrid: global first, then local)
const { validResults, expiredScrapers, allExpired, source } = await hybridCacheService.getCachedResults(type, tmdbId, season, episode, userSettings);
// 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);
}
}
}
// Execute individual scraper
private async executeScraper(
// 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 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, {
// 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
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
}),
});