mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-24 03:27:43 +00:00
Merge branch 'tapframe:main' into fix-timeline-time
This commit is contained in:
commit
f72404e22a
25 changed files with 3212 additions and 463 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -56,3 +56,4 @@ src/screens/xavio.md
|
|||
/KSPlayer
|
||||
/exobase
|
||||
ffmpegreadme.md
|
||||
toast.md
|
||||
|
|
|
|||
119
package-lock.json
generated
119
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -183,13 +183,32 @@ const KSPlayerCore: React.FC = () => {
|
|||
try {
|
||||
// Validate URL first
|
||||
const urlObj = new URL(url);
|
||||
|
||||
|
||||
// Only decode if the URL appears to be double-encoded
|
||||
// Check if URL contains encoded characters that shouldn't be there
|
||||
const hasDoubleEncoding = url.includes('%25') ||
|
||||
(url.includes('%2F') && url.includes('//')) ||
|
||||
(url.includes('%3A') && url.includes('://'));
|
||||
|
||||
// Be more conservative - only check for clear double-encoding indicators
|
||||
|
||||
// Check 1: %25 indicates double-encoded % character
|
||||
const hasDoubleEncodedPercent = url.includes('%25');
|
||||
|
||||
// Check 2: Only flag %2F + // if encoded slashes appear in the path/domain part
|
||||
// (not just in query params where they might be legitimate base64/etc)
|
||||
const hasProblematicEncodedSlashes = (() => {
|
||||
const beforeQuery = url.split('?')[0]; // Get URL before query params
|
||||
return beforeQuery.includes('%2F') && beforeQuery.includes('//');
|
||||
})();
|
||||
|
||||
// Check 3: Only flag %3A + :// if colons are encoded in the scheme
|
||||
const hasProblematicEncodedColons = (() => {
|
||||
const schemeEnd = url.indexOf('://');
|
||||
if (schemeEnd === -1) return false;
|
||||
const schemePart = url.substring(0, schemeEnd);
|
||||
return schemePart.includes('%3A');
|
||||
})();
|
||||
|
||||
const hasDoubleEncoding = hasDoubleEncodedPercent ||
|
||||
hasProblematicEncodedSlashes ||
|
||||
hasProblematicEncodedColons;
|
||||
|
||||
if (hasDoubleEncoding) {
|
||||
logger.log('[VideoPlayer] Detected double-encoded URL, decoding once');
|
||||
return decodeURIComponent(url);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { DeviceEventEmitter } from 'react-native';
|
||||
import { Share } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Toast } from 'toastify-react-native';
|
||||
import DropUpMenu from '../components/home/DropUpMenu';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -40,6 +45,7 @@ interface LibraryItem extends StreamingContent {
|
|||
imdbId?: string;
|
||||
traktId: number;
|
||||
images?: TraktImages;
|
||||
watched?: boolean;
|
||||
}
|
||||
|
||||
interface TraktDisplayItem {
|
||||
|
|
@ -205,6 +211,9 @@ const LibraryScreen = () => {
|
|||
const [filter, setFilter] = useState<'trakt' | 'movies' | 'series'>('movies');
|
||||
const [showTraktContent, setShowTraktContent] = useState(false);
|
||||
const [selectedTraktFolder, setSelectedTraktFolder] = useState<string | null>(null);
|
||||
// DropUpMenu state
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
|
@ -267,7 +276,22 @@ const LibraryScreen = () => {
|
|||
setLoading(true);
|
||||
try {
|
||||
const items = await catalogService.getLibraryItems();
|
||||
setLibraryItems(items as LibraryItem[]);
|
||||
// Load watched status for each item from AsyncStorage
|
||||
const updatedItems = await Promise.all(items.map(async (item) => {
|
||||
// Map StreamingContent to LibraryItem shape
|
||||
const libraryItem: LibraryItem = {
|
||||
...item,
|
||||
gradient: Array.isArray((item as any).gradient) ? (item as any).gradient : ['#222', '#444'],
|
||||
traktId: typeof (item as any).traktId === 'number' ? (item as any).traktId : 0,
|
||||
};
|
||||
const key = `watched:${item.type}:${item.id}`;
|
||||
const watched = await AsyncStorage.getItem(key);
|
||||
return {
|
||||
...libraryItem,
|
||||
watched: watched === 'true'
|
||||
};
|
||||
}));
|
||||
setLibraryItems(updatedItems);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load library:', error);
|
||||
} finally {
|
||||
|
|
@ -278,14 +302,37 @@ const LibraryScreen = () => {
|
|||
loadLibrary();
|
||||
|
||||
// Subscribe to library updates
|
||||
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
|
||||
setLibraryItems(items as LibraryItem[]);
|
||||
const unsubscribe = catalogService.subscribeToLibraryUpdates(async (items) => {
|
||||
// Sync watched status on update
|
||||
const updatedItems = await Promise.all(items.map(async (item) => {
|
||||
// Map StreamingContent to LibraryItem shape
|
||||
const libraryItem: LibraryItem = {
|
||||
...item,
|
||||
gradient: Array.isArray((item as any).gradient) ? (item as any).gradient : ['#222', '#444'],
|
||||
traktId: typeof (item as any).traktId === 'number' ? (item as any).traktId : 0,
|
||||
};
|
||||
const key = `watched:${item.type}:${item.id}`;
|
||||
const watched = await AsyncStorage.getItem(key);
|
||||
return {
|
||||
...libraryItem,
|
||||
watched: watched === 'true'
|
||||
};
|
||||
}));
|
||||
setLibraryItems(updatedItems);
|
||||
});
|
||||
|
||||
// Listen for watched status changes
|
||||
const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', loadLibrary);
|
||||
|
||||
// Refresh when screen regains focus
|
||||
const focusSub = navigation.addListener('focus', loadLibrary);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
watchedSub.remove();
|
||||
focusSub();
|
||||
};
|
||||
}, []);
|
||||
}, [navigation]);
|
||||
|
||||
const filteredItems = libraryItems.filter(item => {
|
||||
if (filter === 'movies') return item.type === 'movie';
|
||||
|
|
@ -348,17 +395,25 @@ const LibraryScreen = () => {
|
|||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width: itemWidth }]}
|
||||
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
|
||||
onLongPress={() => {
|
||||
setSelectedItem(item);
|
||||
setMenuVisible(true);
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View>
|
||||
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
|
||||
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
|
||||
<Image
|
||||
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
|
||||
style={styles.poster}
|
||||
contentFit="cover"
|
||||
transition={300}
|
||||
/>
|
||||
|
||||
{item.watched && (
|
||||
<View style={styles.watchedIndicator}>
|
||||
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success || '#4CAF50'} />
|
||||
</View>
|
||||
)}
|
||||
{item.progress !== undefined && item.progress < 1 && (
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View
|
||||
|
|
@ -370,7 +425,7 @@ const LibraryScreen = () => {
|
|||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.white }]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -932,6 +987,62 @@ const LibraryScreen = () => {
|
|||
{showTraktContent ? renderTraktContent() : renderContent()}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* DropUpMenu integration */}
|
||||
{selectedItem && (
|
||||
<DropUpMenu
|
||||
visible={menuVisible}
|
||||
onClose={() => setMenuVisible(false)}
|
||||
item={selectedItem}
|
||||
isWatched={!!selectedItem.watched}
|
||||
isSaved={true} // Since this is from library, it's always saved
|
||||
onOptionSelect={async (option) => {
|
||||
if (!selectedItem) return;
|
||||
switch (option) {
|
||||
case 'library': {
|
||||
try {
|
||||
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
|
||||
Toast.info('Removed from Library');
|
||||
setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type)));
|
||||
setMenuVisible(false);
|
||||
} catch (error) {
|
||||
Toast.error('Failed to update Library');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'watched': {
|
||||
try {
|
||||
// Use AsyncStorage to store watched status by key
|
||||
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
|
||||
const newWatched = !selectedItem.watched;
|
||||
await AsyncStorage.setItem(key, newWatched ? 'true' : 'false');
|
||||
Toast.info(newWatched ? 'Marked as Watched' : 'Marked as Unwatched');
|
||||
// Instantly update local state
|
||||
setLibraryItems(prev => prev.map(item =>
|
||||
item.id === selectedItem.id && item.type === selectedItem.type
|
||||
? { ...item, watched: newWatched }
|
||||
: item
|
||||
));
|
||||
} catch (error) {
|
||||
Toast.error('Failed to update watched status');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'share': {
|
||||
let url = '';
|
||||
if (selectedItem.id) {
|
||||
url = `https://www.imdb.com/title/${selectedItem.id}/`;
|
||||
}
|
||||
const message = `${selectedItem.name}\n${url}`;
|
||||
Share.share({ message, url, title: selectedItem.name });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -947,6 +1058,14 @@ const styles = StyleSheet.create({
|
|||
right: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
watchedIndicator: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
borderRadius: 12,
|
||||
padding: 2,
|
||||
zIndex: 2,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -778,8 +778,8 @@ const InfoTooltip: React.FC<{ text: string; colors: any }> = ({ text, colors })
|
|||
);
|
||||
|
||||
// Helper component for status badges
|
||||
const StatusBadge: React.FC<{
|
||||
status: 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error';
|
||||
const StatusBadge: React.FC<{
|
||||
status: 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' | 'limited';
|
||||
colors: any;
|
||||
}> = ({ status, colors }) => {
|
||||
const getStatusConfig = () => {
|
||||
|
|
@ -792,6 +792,8 @@ const StatusBadge: React.FC<{
|
|||
return { color: colors.primary, text: 'Available' };
|
||||
case 'platform-disabled':
|
||||
return { color: '#FF9500', text: 'Platform Disabled' };
|
||||
case 'limited':
|
||||
return { color: '#FF9500', text: 'Limited' };
|
||||
case 'error':
|
||||
return { color: '#FF3B30', text: 'Error' };
|
||||
default:
|
||||
|
|
@ -919,9 +921,10 @@ const PluginsScreen: React.FC = () => {
|
|||
}));
|
||||
};
|
||||
|
||||
const getScraperStatus = (scraper: ScraperInfo): 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' => {
|
||||
const getScraperStatus = (scraper: ScraperInfo): 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' | 'limited' => {
|
||||
if (scraper.manifestEnabled === false) return 'disabled';
|
||||
if (scraper.disabledPlatforms?.includes(Platform.OS as 'ios' | 'android')) return 'platform-disabled';
|
||||
if (scraper.limited) return 'limited';
|
||||
if (scraper.enabled) return 'enabled';
|
||||
return 'available';
|
||||
};
|
||||
|
|
@ -1293,7 +1296,7 @@ const PluginsScreen: React.FC = () => {
|
|||
};
|
||||
|
||||
// Define available quality options
|
||||
const qualityOptions = ['Auto', '2160p', '4K', '1080p', '720p', '360p', 'DV', 'HDR', 'REMUX', '480p', 'CAM', 'TS'];
|
||||
const qualityOptions = ['Auto', 'Adaptive', '2160p', '4K', '1080p', '720p', '360p', 'DV', 'HDR', 'REMUX', '480p', 'CAM', 'TS'];
|
||||
|
||||
|
||||
|
||||
|
|
@ -1816,9 +1819,13 @@ const PluginsScreen: React.FC = () => {
|
|||
<View style={[styles.section, styles.lastSection]}>
|
||||
<Text style={styles.sectionTitle}>About Plugins</Text>
|
||||
<Text style={styles.infoText}>
|
||||
Plugins are JavaScript modules that can search for streaming links from various sources.
|
||||
Plugins are JavaScript modules that can search for streaming links from various sources.
|
||||
They run locally on your device and can be installed from trusted repositories.
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.infoText, { marginTop: 8, fontSize: 13, color: colors.mediumEmphasis }]}>
|
||||
<Text style={{ fontWeight: '600' }}>Note:</Text> Providers marked as "Limited" depend on external APIs that may stop working without notice.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import { MaterialIcons } from '@expo/vector-icons';
|
|||
import { catalogService, StreamingContent } from '../services/catalogService';
|
||||
import { Image } from 'expo-image';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { DropUpMenu } from '../components/home/DropUpMenu';
|
||||
import { DeviceEventEmitter, Share } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
|
|
@ -207,7 +209,26 @@ const SearchScreen = () => {
|
|||
const inputRef = useRef<TextInput>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// DropUpMenu state
|
||||
const [menuVisible, setMenuVisible] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<StreamingContent | null>(null);
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const [isWatched, setIsWatched] = useState(false);
|
||||
const [refreshFlag, setRefreshFlag] = React.useState(false);
|
||||
|
||||
// Update isSaved and isWatched when selectedItem changes
|
||||
useEffect(() => {
|
||||
if (!selectedItem) return;
|
||||
(async () => {
|
||||
// Check if item is in library
|
||||
const items = await catalogService.getLibraryItems();
|
||||
const found = items.find((libItem: any) => libItem.id === selectedItem.id && libItem.type === selectedItem.type);
|
||||
setIsSaved(!!found);
|
||||
// Check watched status
|
||||
const val = await AsyncStorage.getItem(`watched:${selectedItem.type}:${selectedItem.id}`);
|
||||
setIsWatched(val === 'true');
|
||||
})();
|
||||
}, [selectedItem]);
|
||||
// Animation values
|
||||
const searchBarWidth = useSharedValue(width - 32);
|
||||
const searchBarOpacity = useSharedValue(1);
|
||||
|
|
@ -441,35 +462,79 @@ const SearchScreen = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const renderHorizontalItem = ({ item, index }: { item: StreamingContent, index: number }) => {
|
||||
const SearchResultItem = ({ item, index, navigation, setSelectedItem, setMenuVisible, currentTheme, refreshFlag }: {
|
||||
item: StreamingContent;
|
||||
index: number;
|
||||
navigation: any;
|
||||
setSelectedItem: (item: StreamingContent) => void;
|
||||
setMenuVisible: (visible: boolean) => void;
|
||||
currentTheme: any;
|
||||
refreshFlag: boolean;
|
||||
}) => {
|
||||
const [inLibrary, setInLibrary] = React.useState(!!item.inLibrary);
|
||||
const [watched, setWatched] = React.useState(false);
|
||||
// Re-check status when refreshFlag changes
|
||||
React.useEffect(() => {
|
||||
AsyncStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
|
||||
const items = catalogService.getLibraryItems();
|
||||
const found = items.find((libItem: any) => libItem.id === item.id && libItem.type === item.type);
|
||||
setInLibrary(!!found);
|
||||
}, [refreshFlag, item.id, item.type]);
|
||||
React.useEffect(() => {
|
||||
const updateWatched = () => {
|
||||
AsyncStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
|
||||
};
|
||||
updateWatched();
|
||||
const sub = DeviceEventEmitter.addListener('watchedStatusChanged', updateWatched);
|
||||
return () => sub.remove();
|
||||
}, [item.id, item.type]);
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
|
||||
const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type);
|
||||
setInLibrary(!!found);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [item.id, item.type]);
|
||||
return (
|
||||
<AnimatedTouchable
|
||||
style={styles.horizontalItem}
|
||||
onPress={() => {
|
||||
navigation.navigate('Metadata', { id: item.id, type: item.type });
|
||||
}}
|
||||
onLongPress={() => {
|
||||
setSelectedItem(item);
|
||||
setMenuVisible(true);
|
||||
// Do NOT toggle refreshFlag here
|
||||
}}
|
||||
delayLongPress={300}
|
||||
entering={FadeIn.duration(300).delay(index * 50)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.horizontalItemPosterContainer, {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
borderColor: 'rgba(255,255,255,0.05)'
|
||||
}]}>
|
||||
}]}>
|
||||
<Image
|
||||
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
|
||||
style={styles.horizontalItemPoster}
|
||||
contentFit="cover"
|
||||
transition={300}
|
||||
/>
|
||||
<View style={styles.itemTypeContainer}>
|
||||
<Text style={[styles.itemTypeText, { color: currentTheme.colors.white }]}>
|
||||
{item.type === 'movie' ? 'MOVIE' : 'SERIES'}
|
||||
</Text>
|
||||
</View>
|
||||
{/* Bookmark and watched icons top right, bookmark to the left of watched */}
|
||||
{inLibrary && (
|
||||
<View style={[styles.libraryBadge, { position: 'absolute', top: 8, right: 36, backgroundColor: 'transparent', zIndex: 2 }] }>
|
||||
<MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} />
|
||||
</View>
|
||||
)}
|
||||
{watched && (
|
||||
<View style={[styles.watchedIndicator, { position: 'absolute', top: 8, right: 8, backgroundColor: 'transparent', zIndex: 2 }] }>
|
||||
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} />
|
||||
</View>
|
||||
)}
|
||||
{item.imdbRating && (
|
||||
<View style={styles.ratingContainer}>
|
||||
<MaterialIcons name="star" size={12} color="#FFC107" />
|
||||
<Text style={[styles.ratingText, { color: currentTheme.colors.white }]}>
|
||||
<Text style={[styles.ratingText, { color: currentTheme.colors.white }]}>
|
||||
{item.imdbRating}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -482,7 +547,7 @@ const SearchScreen = () => {
|
|||
{item.name}
|
||||
</Text>
|
||||
{item.year && (
|
||||
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray }]}>
|
||||
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray }]}>
|
||||
{item.year}
|
||||
</Text>
|
||||
)}
|
||||
|
|
@ -506,6 +571,17 @@ const SearchScreen = () => {
|
|||
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
|
||||
const headerHeight = headerBaseHeight + topSpacing + 60;
|
||||
|
||||
useEffect(() => {
|
||||
const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', () => setRefreshFlag(f => !f));
|
||||
const librarySub = catalogService.subscribeToLibraryUpdates(() => setRefreshFlag(f => !f));
|
||||
const focusSub = navigation.addListener('focus', () => setRefreshFlag(f => !f));
|
||||
return () => {
|
||||
watchedSub.remove();
|
||||
librarySub();
|
||||
focusSub();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
|
||||
|
|
@ -520,13 +596,11 @@ const SearchScreen = () => {
|
|||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
|
||||
{/* Fixed position header background to prevent shifts */}
|
||||
<View style={[styles.headerBackground, {
|
||||
height: headerHeight,
|
||||
backgroundColor: currentTheme.colors.darkBackground
|
||||
}]} />
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Header Section with proper top spacing */}
|
||||
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
|
||||
|
|
@ -580,7 +654,6 @@ const SearchScreen = () => {
|
|||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content Container */}
|
||||
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
{searching ? (
|
||||
|
|
@ -600,10 +673,10 @@ const SearchScreen = () => {
|
|||
size={64}
|
||||
color={currentTheme.colors.lightGray}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
||||
Keep typing...
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||
Type at least 2 characters to search
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
|
@ -617,10 +690,10 @@ const SearchScreen = () => {
|
|||
size={64}
|
||||
color={currentTheme.colors.lightGray}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
||||
No results found
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||
Try different keywords or check your spelling
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
|
@ -634,48 +707,110 @@ const SearchScreen = () => {
|
|||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{!query.trim() && renderRecentSearches()}
|
||||
|
||||
{movieResults.length > 0 && (
|
||||
<Animated.View
|
||||
style={styles.carouselContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
|
||||
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
|
||||
Movies ({movieResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
data={movieResults}
|
||||
renderItem={renderHorizontalItem}
|
||||
renderItem={({ item, index }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
index={index}
|
||||
navigation={navigation}
|
||||
setSelectedItem={setSelectedItem}
|
||||
setMenuVisible={setMenuVisible}
|
||||
currentTheme={currentTheme}
|
||||
refreshFlag={refreshFlag}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={item => `movie-${item.id}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
extraData={refreshFlag}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{seriesResults.length > 0 && (
|
||||
<Animated.View
|
||||
style={styles.carouselContainer}
|
||||
entering={FadeIn.duration(300).delay(50)}
|
||||
>
|
||||
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
|
||||
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
|
||||
TV Shows ({seriesResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
data={seriesResults}
|
||||
renderItem={renderHorizontalItem}
|
||||
renderItem={({ item, index }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
index={index}
|
||||
navigation={navigation}
|
||||
setSelectedItem={setSelectedItem}
|
||||
setMenuVisible={setMenuVisible}
|
||||
currentTheme={currentTheme}
|
||||
refreshFlag={refreshFlag}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={item => `series-${item.id}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
extraData={refreshFlag}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
</Animated.ScrollView>
|
||||
)}
|
||||
</View>
|
||||
{/* DropUpMenu integration for search results */}
|
||||
{selectedItem && (
|
||||
<DropUpMenu
|
||||
visible={menuVisible}
|
||||
onClose={() => setMenuVisible(false)}
|
||||
item={selectedItem}
|
||||
isSaved={isSaved}
|
||||
isWatched={isWatched}
|
||||
onOptionSelect={async (option: string) => {
|
||||
if (!selectedItem) return;
|
||||
switch (option) {
|
||||
case 'share': {
|
||||
let url = '';
|
||||
if (selectedItem.id) {
|
||||
url = `https://www.imdb.com/title/${selectedItem.id}/`;
|
||||
}
|
||||
const message = `${selectedItem.name}\n${url}`;
|
||||
Share.share({ message, url, title: selectedItem.name });
|
||||
break;
|
||||
}
|
||||
case 'library': {
|
||||
if (isSaved) {
|
||||
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
|
||||
setIsSaved(false);
|
||||
} else {
|
||||
await catalogService.addToLibrary(selectedItem);
|
||||
setIsSaved(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'watched': {
|
||||
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
|
||||
const newWatched = !isWatched;
|
||||
await AsyncStorage.setItem(key, newWatched ? 'true' : 'false');
|
||||
setIsWatched(newWatched);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
|
@ -897,19 +1032,6 @@ const styles = StyleSheet.create({
|
|||
marginBottom: 16,
|
||||
borderRadius: 4,
|
||||
},
|
||||
itemTypeContainer: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
itemTypeText: {
|
||||
fontSize: isTablet ? 7 : 8,
|
||||
fontWeight: '700',
|
||||
},
|
||||
ratingContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
|
|
@ -954,6 +1076,24 @@ const styles = StyleSheet.create({
|
|||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
watchedIndicator: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
borderRadius: 12,
|
||||
padding: 2,
|
||||
zIndex: 2,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
libraryBadge: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 36,
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
zIndex: 2,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
|
||||
export default SearchScreen;
|
||||
|
|
@ -37,17 +37,18 @@ import { useMetadata } from '../hooks/useMetadata';
|
|||
import { useMetadataAssets } from '../hooks/useMetadataAssets';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useTrailer } from '../contexts/TrailerContext';
|
||||
import { Stream } from '../types/metadata';
|
||||
import { Stream, GroupedStreams } from '../types/metadata';
|
||||
import { tmdbService } from '../services/tmdbService';
|
||||
import { stremioService } from '../services/stremioService';
|
||||
import { localScraperService } from '../services/localScraperService';
|
||||
import { hybridCacheService } from '../services/hybridCacheService';
|
||||
import { VideoPlayerService } from '../services/videoPlayerService';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import QualityBadge from '../components/metadata/QualityBadge';
|
||||
import { logger } from '../utils/logger';
|
||||
import { isMkvStream } from '../utils/mkvDetection';
|
||||
import CustomAlert from '../components/CustomAlert';
|
||||
import { toast, ToastPosition } from '@backpackapp-io/react-native-toast';
|
||||
import { Toast } from 'toastify-react-native';
|
||||
import { useDownloads } from '../contexts/DownloadsContext';
|
||||
|
||||
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
|
||||
|
|
@ -233,10 +234,7 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
|
|||
|
||||
// Use toast for Android, custom alert for iOS
|
||||
if (Platform.OS === 'android') {
|
||||
toast('Stream URL copied to clipboard!', {
|
||||
duration: 2000,
|
||||
position: ToastPosition.BOTTOM,
|
||||
});
|
||||
Toast.success('Stream URL copied to clipboard!', 'bottom');
|
||||
} else {
|
||||
// iOS uses custom alert
|
||||
setTimeout(() => {
|
||||
|
|
@ -246,10 +244,7 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
|
|||
} catch (error) {
|
||||
// Fallback: show URL in alert if clipboard fails
|
||||
if (Platform.OS === 'android') {
|
||||
toast(`Stream URL: ${stream.url}`, {
|
||||
duration: 3000,
|
||||
position: ToastPosition.BOTTOM,
|
||||
});
|
||||
Toast.info(`Stream URL: ${stream.url}`, 'bottom');
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
showAlert('Stream URL', stream.url);
|
||||
|
|
@ -322,7 +317,7 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
|
|||
url,
|
||||
headers: (stream.headers as any) || undefined,
|
||||
});
|
||||
toast('Download started', { duration: 1500, position: ToastPosition.BOTTOM });
|
||||
Toast.success('Download started', 'bottom');
|
||||
} catch {}
|
||||
}, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title]);
|
||||
|
||||
|
|
@ -504,10 +499,11 @@ export const StreamsScreen = () => {
|
|||
const { colors } = currentTheme;
|
||||
const { pauseTrailer, resumeTrailer } = useTrailer();
|
||||
|
||||
// Add ref to prevent excessive updates
|
||||
// Add refs to prevent excessive updates and duplicate loads
|
||||
const isMounted = useRef(true);
|
||||
const loadStartTimeRef = useRef(0);
|
||||
const hasDoneInitialLoadRef = useRef(false);
|
||||
const isLoadingStreamsRef = useRef(false);
|
||||
|
||||
// CustomAlert state
|
||||
const [alertVisible, setAlertVisible] = useState(false);
|
||||
|
|
@ -733,81 +729,233 @@ export const StreamsScreen = () => {
|
|||
}
|
||||
}, [selectedProvider, availableProviders, episodeStreams, groupedStreams, type]);
|
||||
|
||||
// Check for cached results immediately on mount
|
||||
useEffect(() => {
|
||||
const checkCachedResults = async () => {
|
||||
if (!settings.enableLocalScrapers) return;
|
||||
|
||||
try {
|
||||
let season: number | undefined;
|
||||
let episode: number | undefined;
|
||||
|
||||
if (episodeId && episodeId.includes(':')) {
|
||||
const parts = episodeId.split(':');
|
||||
if (parts.length >= 3) {
|
||||
season = parseInt(parts[1], 10);
|
||||
episode = parseInt(parts[2], 10);
|
||||
}
|
||||
}
|
||||
|
||||
const installedScrapers = await localScraperService.getInstalledScrapers();
|
||||
const userSettings = {
|
||||
enableLocalScrapers: settings.enableLocalScrapers,
|
||||
enabledScrapers: new Set(
|
||||
installedScrapers
|
||||
.filter(scraper => scraper.enabled)
|
||||
.map(scraper => scraper.id)
|
||||
)
|
||||
};
|
||||
const cachedResults = await hybridCacheService.getCachedResults(type, id, season, episode, userSettings);
|
||||
if (cachedResults.validResults.length > 0) {
|
||||
logger.log(`🔍 Found ${cachedResults.validResults.length} cached scraper results on mount`);
|
||||
|
||||
// If we have cached results, trigger the loading flow immediately
|
||||
if (!hasDoneInitialLoadRef.current) {
|
||||
logger.log('🚀 Triggering immediate load due to cached results');
|
||||
// Force a re-render to ensure cached results are displayed
|
||||
setHasStreamProviders(true);
|
||||
setStreamsLoadStart(Date.now());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.log('[StreamsScreen] Error checking cached results on mount:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkCachedResults();
|
||||
}, [type, id, episodeId, settings.enableLocalScrapers]);
|
||||
|
||||
// Update useEffect to check for sources
|
||||
useEffect(() => {
|
||||
// Reset initial load state when content changes
|
||||
hasDoneInitialLoadRef.current = false;
|
||||
isLoadingStreamsRef.current = false;
|
||||
|
||||
const checkProviders = async () => {
|
||||
if (__DEV__) console.log('[StreamsScreen] checkProviders() start', { id, type, episodeId, fromPlayer });
|
||||
logger.log(`[StreamsScreen] checkProviders() start id=${id} type=${type} episodeId=${episodeId || 'none'} fromPlayer=${!!fromPlayer}`);
|
||||
// Check for Stremio addons
|
||||
const hasStremioProviders = await stremioService.hasStreamProviders();
|
||||
if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders);
|
||||
|
||||
// Check for local scrapers (only if enabled in settings)
|
||||
const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers();
|
||||
if (__DEV__) console.log('[StreamsScreen] hasLocalScrapers:', hasLocalScrapers, 'enableLocalScrapers:', settings.enableLocalScrapers);
|
||||
|
||||
// We have providers if we have either Stremio addons OR enabled local scrapers
|
||||
const hasProviders = hasStremioProviders || hasLocalScrapers;
|
||||
logger.log(`[StreamsScreen] provider check: hasProviders=${hasProviders}`);
|
||||
|
||||
if (!isMounted.current) return;
|
||||
// Prevent duplicate calls if already loading
|
||||
if (isLoadingStreamsRef.current) {
|
||||
if (__DEV__) console.log('[StreamsScreen] checkProviders() skipping - already loading');
|
||||
return;
|
||||
}
|
||||
|
||||
setHasStreamProviders(hasProviders);
|
||||
setHasStremioStreamProviders(hasStremioProviders);
|
||||
isLoadingStreamsRef.current = true;
|
||||
|
||||
if (!hasProviders) {
|
||||
logger.log('[StreamsScreen] No providers detected; scheduling no-sources UI');
|
||||
const timer = setTimeout(() => {
|
||||
if (isMounted.current) setShowNoSourcesError(true);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
// For series episodes, do not wait for metadata; load directly when episodeId is present
|
||||
if (episodeId) {
|
||||
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
|
||||
setLoadingProviders({
|
||||
'stremio': true
|
||||
});
|
||||
setSelectedEpisode(episodeId);
|
||||
setStreamsLoadStart(Date.now());
|
||||
if (__DEV__) console.log('[StreamsScreen] calling loadEpisodeStreams', episodeId);
|
||||
loadEpisodeStreams(episodeId);
|
||||
} else if (type === 'movie') {
|
||||
logger.log(`🎬 Loading movie streams for: ${id}`);
|
||||
setStreamsLoadStart(Date.now());
|
||||
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (movie)', id);
|
||||
loadStreams();
|
||||
} else if (type === 'tv') {
|
||||
// TV/live content – fetch streams directly
|
||||
logger.log(`📺 Loading TV streams for: ${id}`);
|
||||
setLoadingProviders({
|
||||
'stremio': true
|
||||
});
|
||||
setStreamsLoadStart(Date.now());
|
||||
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (tv)', id);
|
||||
loadStreams();
|
||||
} else {
|
||||
// Fallback: series without explicit episodeId (or other types) – fetch streams directly
|
||||
logger.log(`🎬 Loading streams for: ${id}`);
|
||||
setLoadingProviders({
|
||||
'stremio': true
|
||||
});
|
||||
setStreamsLoadStart(Date.now());
|
||||
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (fallback)', id);
|
||||
loadStreams();
|
||||
}
|
||||
|
||||
// Reset autoplay state when content changes
|
||||
setAutoplayTriggered(false);
|
||||
if (settings.autoplayBestStream && !fromPlayer) {
|
||||
setIsAutoplayWaiting(true);
|
||||
logger.log('🔄 Autoplay enabled, waiting for best stream...');
|
||||
} else {
|
||||
setIsAutoplayWaiting(false);
|
||||
if (fromPlayer) {
|
||||
logger.log('🚫 Autoplay disabled: returning from player');
|
||||
try {
|
||||
// Check for Stremio addons
|
||||
const hasStremioProviders = await stremioService.hasStreamProviders();
|
||||
if (__DEV__) console.log('[StreamsScreen] hasStremioProviders:', hasStremioProviders);
|
||||
|
||||
// Check for local scrapers (only if enabled in settings)
|
||||
const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers();
|
||||
if (__DEV__) console.log('[StreamsScreen] hasLocalScrapers:', hasLocalScrapers, 'enableLocalScrapers:', settings.enableLocalScrapers);
|
||||
|
||||
// Check for cached results (this covers both local and global cache)
|
||||
let hasCachedResults = false;
|
||||
if (settings.enableLocalScrapers) {
|
||||
try {
|
||||
// Check if there are any cached streams for this content
|
||||
let season: number | undefined;
|
||||
let episode: number | undefined;
|
||||
|
||||
if (episodeId && episodeId.includes(':')) {
|
||||
const parts = episodeId.split(':');
|
||||
if (parts.length >= 3) {
|
||||
season = parseInt(parts[1], 10);
|
||||
episode = parseInt(parts[2], 10);
|
||||
}
|
||||
}
|
||||
|
||||
const installedScrapers = await localScraperService.getInstalledScrapers();
|
||||
const userSettings = {
|
||||
enableLocalScrapers: settings.enableLocalScrapers,
|
||||
enabledScrapers: new Set(
|
||||
installedScrapers
|
||||
.filter(scraper => scraper.enabled)
|
||||
.map(scraper => scraper.id)
|
||||
)
|
||||
};
|
||||
const cachedStreams = await hybridCacheService.getCachedStreams(type, id, season, episode, userSettings);
|
||||
hasCachedResults = cachedStreams.length > 0;
|
||||
if (__DEV__) console.log('[StreamsScreen] hasCachedResults:', hasCachedResults, 'cached streams count:', cachedStreams.length, 'season:', season, 'episode:', episode);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.log('[StreamsScreen] Error checking cached results:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// We have providers if we have Stremio addons, enabled local scrapers, OR cached results
|
||||
const hasProviders = hasStremioProviders || hasLocalScrapers || hasCachedResults;
|
||||
logger.log(`[StreamsScreen] provider check: hasProviders=${hasProviders} (stremio:${hasStremioProviders}, local:${hasLocalScrapers}, cached:${hasCachedResults})`);
|
||||
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setHasStreamProviders(hasProviders);
|
||||
setHasStremioStreamProviders(hasStremioProviders);
|
||||
|
||||
if (!hasProviders) {
|
||||
// If we have local scrapers enabled but no cached results yet, wait a bit longer
|
||||
if (settings.enableLocalScrapers && !hasCachedResults) {
|
||||
logger.log('[StreamsScreen] No providers detected but checking for cached results; waiting longer');
|
||||
const timer = setTimeout(() => {
|
||||
if (isMounted.current) setShowNoSourcesError(true);
|
||||
}, 2000); // Wait 2 seconds for cached results
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
logger.log('[StreamsScreen] No providers detected; scheduling no-sources UI');
|
||||
const timer = setTimeout(() => {
|
||||
if (isMounted.current) setShowNoSourcesError(true);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
} else {
|
||||
// Check for cached streams first before loading
|
||||
if (settings.enableLocalScrapers) {
|
||||
try {
|
||||
let season: number | undefined;
|
||||
let episode: number | undefined;
|
||||
|
||||
if (episodeId && episodeId.includes(':')) {
|
||||
const parts = episodeId.split(':');
|
||||
if (parts.length >= 3) {
|
||||
season = parseInt(parts[1], 10);
|
||||
episode = parseInt(parts[2], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have cached streams and load them immediately
|
||||
const cachedStreams = await hybridCacheService.getCachedStreams(type, id, season, episode);
|
||||
if (cachedStreams.length > 0) {
|
||||
logger.log(`🎯 Found ${cachedStreams.length} cached streams, displaying immediately`);
|
||||
|
||||
// Group cached streams by scraper for proper display
|
||||
const groupedCachedStreams: GroupedStreams = {};
|
||||
const scrapersWithCachedResults = new Set<string>();
|
||||
|
||||
// Get cached results to determine which scrapers have results
|
||||
const cachedResults = await hybridCacheService.getCachedResults(type, id, season, episode);
|
||||
|
||||
for (const result of cachedResults.validResults) {
|
||||
if (result.success && result.streams && result.streams.length > 0) {
|
||||
groupedCachedStreams[result.scraperId] = {
|
||||
addonName: result.scraperName,
|
||||
streams: result.streams
|
||||
};
|
||||
scrapersWithCachedResults.add(result.scraperId);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the streams state immediately if we have cached results
|
||||
if (Object.keys(groupedCachedStreams).length > 0) {
|
||||
logger.log(`🚀 Immediately displaying ${Object.keys(groupedCachedStreams).length} cached scrapers with streams`);
|
||||
// This will be handled by the useMetadata hook integration
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.log('[StreamsScreen] Error checking cached streams:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// For series episodes, do not wait for metadata; load directly when episodeId is present
|
||||
if (episodeId) {
|
||||
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
|
||||
setLoadingProviders({
|
||||
'stremio': true
|
||||
});
|
||||
setSelectedEpisode(episodeId);
|
||||
setStreamsLoadStart(Date.now());
|
||||
if (__DEV__) console.log('[StreamsScreen] calling loadEpisodeStreams', episodeId);
|
||||
loadEpisodeStreams(episodeId);
|
||||
} else if (type === 'movie') {
|
||||
logger.log(`🎬 Loading movie streams for: ${id}`);
|
||||
setStreamsLoadStart(Date.now());
|
||||
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (movie)', id);
|
||||
loadStreams();
|
||||
} else if (type === 'tv') {
|
||||
// TV/live content – fetch streams directly
|
||||
logger.log(`📺 Loading TV streams for: ${id}`);
|
||||
setLoadingProviders({
|
||||
'stremio': true
|
||||
});
|
||||
setStreamsLoadStart(Date.now());
|
||||
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (tv)', id);
|
||||
loadStreams();
|
||||
} else {
|
||||
// Fallback: series without explicit episodeId (or other types) – fetch streams directly
|
||||
logger.log(`🎬 Loading streams for: ${id}`);
|
||||
setLoadingProviders({
|
||||
'stremio': true
|
||||
});
|
||||
setStreamsLoadStart(Date.now());
|
||||
if (__DEV__) console.log('[StreamsScreen] calling loadStreams (fallback)', id);
|
||||
loadStreams();
|
||||
}
|
||||
|
||||
// Reset autoplay state when content changes
|
||||
setAutoplayTriggered(false);
|
||||
if (settings.autoplayBestStream && !fromPlayer) {
|
||||
setIsAutoplayWaiting(true);
|
||||
logger.log('🔄 Autoplay enabled, waiting for best stream...');
|
||||
} else {
|
||||
setIsAutoplayWaiting(false);
|
||||
if (fromPlayer) {
|
||||
logger.log('🚫 Autoplay disabled: returning from player');
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isLoadingStreamsRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
Image,
|
||||
KeyboardAvoidingView,
|
||||
TouchableWithoutFeedback,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||
|
|
@ -50,6 +51,8 @@ const TMDBSettingsScreen = () => {
|
|||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const [languagePickerVisible, setLanguagePickerVisible] = useState(false);
|
||||
const [languageSearch, setLanguageSearch] = useState('');
|
||||
|
||||
const openAlert = (
|
||||
title: string,
|
||||
|
|
@ -284,165 +287,431 @@ const TMDBSettingsScreen = () => {
|
|||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.switchTextContainer}>
|
||||
<Text style={[styles.switchTitle, { color: currentTheme.colors.text }]}>Enrich Metadata with TMDb</Text>
|
||||
<Text style={[styles.switchDescription, { color: currentTheme.colors.mediumEmphasis }]}>When enabled, the app augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback. Disable to strictly use addon metadata only.</Text>
|
||||
{/* Metadata Enrichment Section */}
|
||||
<View style={[styles.sectionCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Metadata Enrichment</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.enrichMetadataWithTMDB}
|
||||
onValueChange={(v) => updateSetting('enrichMetadataWithTMDB', v)}
|
||||
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
|
||||
thumbColor={Platform.OS === 'android' ? (settings.enrichMetadataWithTMDB ? currentTheme.colors.white : currentTheme.colors.white) : ''}
|
||||
ios_backgroundColor={'rgba(255,255,255,0.1)'}
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.switchTextContainer}>
|
||||
<Text style={[styles.switchTitle, { color: currentTheme.colors.text }]}>Use Custom TMDb API Key</Text>
|
||||
<Text style={[styles.switchDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Enable to use your own TMDb API key instead of the built-in one.
|
||||
Using your own API key may provide better performance and higher rate limits.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={useCustomKey}
|
||||
onValueChange={toggleUseCustomKey}
|
||||
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
|
||||
thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''}
|
||||
ios_backgroundColor={'rgba(255,255,255,0.1)'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Enhance your content metadata with TMDb data for better details and information.
|
||||
</Text>
|
||||
|
||||
{useCustomKey && (
|
||||
<>
|
||||
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<MaterialIcons
|
||||
name={isKeySet ? "check-circle" : "error-outline"}
|
||||
size={28}
|
||||
color={isKeySet ? currentTheme.colors.success : currentTheme.colors.warning}
|
||||
style={styles.statusIconContainer}
|
||||
/>
|
||||
<View style={styles.statusTextContainer}>
|
||||
<Text style={[styles.statusTitle, { color: currentTheme.colors.text }]}>
|
||||
{isKeySet ? "API Key Active" : "API Key Required"}
|
||||
</Text>
|
||||
<Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{isKeySet
|
||||
? "Your custom TMDb API key is set and active."
|
||||
: "Add your TMDb API key below."}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.text }]}>API Key</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
ref={apiKeyInputRef}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
color: currentTheme.colors.text,
|
||||
borderColor: isInputFocused ? currentTheme.colors.primary : 'transparent'
|
||||
}
|
||||
]}
|
||||
value={apiKey}
|
||||
onChangeText={(text) => {
|
||||
setApiKey(text);
|
||||
if (testResult) setTestResult(null);
|
||||
}}
|
||||
placeholder="Paste your TMDb API key (v3)"
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.pasteButton}
|
||||
onPress={pasteFromClipboard}
|
||||
>
|
||||
<MaterialIcons name="content-paste" size={20} color={currentTheme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={saveApiKey}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save API Key</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isKeySet && (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
|
||||
onPress={clearApiKey}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{testResult && (
|
||||
<View style={[
|
||||
styles.resultMessage,
|
||||
{ backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' }
|
||||
]}>
|
||||
<MaterialIcons
|
||||
name={testResult.success ? "check-circle" : "error"}
|
||||
size={18}
|
||||
color={testResult.success ? currentTheme.colors.success : currentTheme.colors.error}
|
||||
style={styles.resultIcon}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.resultText,
|
||||
{ color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error }
|
||||
]}>
|
||||
{testResult.message}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.helpLink}
|
||||
onPress={openTMDBWebsite}
|
||||
>
|
||||
<MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} />
|
||||
<Text style={[styles.helpText, { color: currentTheme.colors.primary }]}>
|
||||
How to get a TMDb API key?
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
|
||||
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
To get your own TMDb API key (v3), you need to create a TMDb account and request an API key from their website.
|
||||
Using your own API key gives you dedicated quota and may improve app performance.
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Enable Enrichment</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback.
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!useCustomKey && (
|
||||
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
|
||||
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Currently using the built-in TMDb API key. This key is shared among all users.
|
||||
For better performance and reliability, consider using your own API key.
|
||||
</Text>
|
||||
<Switch
|
||||
value={settings.enrichMetadataWithTMDB}
|
||||
onValueChange={(v) => updateSetting('enrichMetadataWithTMDB', v)}
|
||||
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
|
||||
thumbColor={Platform.OS === 'android' ? (settings.enrichMetadataWithTMDB ? currentTheme.colors.white : currentTheme.colors.white) : ''}
|
||||
ios_backgroundColor={'rgba(255,255,255,0.1)'}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{settings.enrichMetadataWithTMDB && (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Localized Text</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Fetch titles and descriptions in your preferred language from TMDb.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.useTmdbLocalizedMetadata}
|
||||
onValueChange={(v) => updateSetting('useTmdbLocalizedMetadata', v)}
|
||||
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
|
||||
thumbColor={Platform.OS === 'android' ? (settings.useTmdbLocalizedMetadata ? currentTheme.colors.white : currentTheme.colors.white) : ''}
|
||||
ios_backgroundColor={'rgba(255,255,255,0.1)'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{settings.useTmdbLocalizedMetadata && (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Language</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Current: {(settings.tmdbLanguagePreference || 'en').toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => setLanguagePickerVisible(true)}
|
||||
style={[styles.languageButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
>
|
||||
<Text style={[styles.languageButtonText, { color: currentTheme.colors.white }]}>Change</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* API Configuration Section */}
|
||||
<View style={[styles.sectionCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name="api" size={20} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>API Configuration</Text>
|
||||
</View>
|
||||
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Configure your TMDb API access for enhanced functionality.
|
||||
</Text>
|
||||
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Custom API Key</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Use your own TMDb API key for better performance and dedicated rate limits.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={useCustomKey}
|
||||
onValueChange={toggleUseCustomKey}
|
||||
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
|
||||
thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''}
|
||||
ios_backgroundColor={'rgba(255,255,255,0.1)'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{useCustomKey && (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* API Key Status */}
|
||||
<View style={styles.statusRow}>
|
||||
<MaterialIcons
|
||||
name={isKeySet ? "check-circle" : "error-outline"}
|
||||
size={20}
|
||||
color={isKeySet ? currentTheme.colors.success : currentTheme.colors.warning}
|
||||
/>
|
||||
<Text style={[styles.statusText, {
|
||||
color: isKeySet ? currentTheme.colors.success : currentTheme.colors.warning
|
||||
}]}>
|
||||
{isKeySet ? "Custom API key active" : "API key required"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* API Key Input */}
|
||||
<View style={styles.apiKeyContainer}>
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
ref={apiKeyInputRef}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
color: currentTheme.colors.text,
|
||||
borderColor: isInputFocused ? currentTheme.colors.primary : 'transparent'
|
||||
}
|
||||
]}
|
||||
value={apiKey}
|
||||
onChangeText={(text) => {
|
||||
setApiKey(text);
|
||||
if (testResult) setTestResult(null);
|
||||
}}
|
||||
placeholder="Paste your TMDb API key (v3)"
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.pasteButton}
|
||||
onPress={pasteFromClipboard}
|
||||
>
|
||||
<MaterialIcons name="content-paste" size={20} color={currentTheme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={saveApiKey}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isKeySet && (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
|
||||
onPress={clearApiKey}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{testResult && (
|
||||
<View style={[
|
||||
styles.resultMessage,
|
||||
{ backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' }
|
||||
]}>
|
||||
<MaterialIcons
|
||||
name={testResult.success ? "check-circle" : "error"}
|
||||
size={16}
|
||||
color={testResult.success ? currentTheme.colors.success : currentTheme.colors.error}
|
||||
style={styles.resultIcon}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.resultText,
|
||||
{ color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error }
|
||||
]}>
|
||||
{testResult.message}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.helpLink}
|
||||
onPress={openTMDBWebsite}
|
||||
>
|
||||
<MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} />
|
||||
<Text style={[styles.helpText, { color: currentTheme.colors.primary }]}>
|
||||
How to get a TMDb API key?
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!useCustomKey && (
|
||||
<View style={styles.infoContainer}>
|
||||
<MaterialIcons name="info-outline" size={18} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Currently using built-in API key. Consider using your own key for better performance.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Language Picker Modal */}
|
||||
<Modal
|
||||
visible={languagePickerVisible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={() => setLanguagePickerVisible(false)}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={() => setLanguagePickerVisible(false)}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<TouchableWithoutFeedback>
|
||||
<View style={[styles.modalContent, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.modalHeader}>
|
||||
<View style={[styles.dragHandle, { backgroundColor: currentTheme.colors.elevation3 }]} />
|
||||
<Text style={[styles.modalTitle, { color: currentTheme.colors.text }]}>Choose Language</Text>
|
||||
<Text style={[styles.modalSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>Select your preferred language for TMDb content</Text>
|
||||
</View>
|
||||
|
||||
{/* Search Section */}
|
||||
<View style={styles.searchSection}>
|
||||
<View style={[styles.searchContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<MaterialIcons name="search" size={20} color={currentTheme.colors.mediumEmphasis} style={styles.searchIcon} />
|
||||
<TextInput
|
||||
placeholder="Search languages..."
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
style={[styles.searchInput, { color: currentTheme.colors.text }]}
|
||||
value={languageSearch}
|
||||
onChangeText={setLanguageSearch}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{languageSearch.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setLanguageSearch('')} style={styles.searchClearButton}>
|
||||
<MaterialIcons name="close" size={20} color={currentTheme.colors.mediumEmphasis} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Popular Languages */}
|
||||
{languageSearch.length === 0 && (
|
||||
<View style={styles.popularSection}>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis }]}>Popular</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.popularChips}
|
||||
>
|
||||
{[
|
||||
{ code: 'en', label: 'EN' },
|
||||
{ code: 'ar', label: 'AR' },
|
||||
{ code: 'es', label: 'ES' },
|
||||
{ code: 'fr', label: 'FR' },
|
||||
{ code: 'de', label: 'DE' },
|
||||
{ code: 'tr', label: 'TR' },
|
||||
].map(({ code, label }) => (
|
||||
<TouchableOpacity
|
||||
key={code}
|
||||
onPress={() => { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }}
|
||||
style={[
|
||||
styles.popularChip,
|
||||
settings.tmdbLanguagePreference === code && styles.selectedChip,
|
||||
{
|
||||
backgroundColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.elevation1,
|
||||
borderColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : 'rgba(255,255,255,0.1)',
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={[
|
||||
styles.popularChipText,
|
||||
settings.tmdbLanguagePreference === code && styles.selectedChipText,
|
||||
{ color: settings.tmdbLanguagePreference === code ? currentTheme.colors.white : currentTheme.colors.text }
|
||||
]}>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* All Languages */}
|
||||
<View style={styles.languagesSection}>
|
||||
<Text style={[
|
||||
styles.sectionTitle,
|
||||
languageSearch.length > 0 && styles.searchResultsTitle,
|
||||
{ color: languageSearch.length > 0 ? currentTheme.colors.text : currentTheme.colors.mediumEmphasis }
|
||||
]}>
|
||||
{languageSearch.length > 0 ? 'Search Results' : 'All Languages'}
|
||||
</Text>
|
||||
|
||||
<ScrollView style={styles.languageList} showsVerticalScrollIndicator={false}>
|
||||
{(() => {
|
||||
const languages = [
|
||||
{ code: 'en', label: 'English', native: 'English' },
|
||||
{ code: 'ar', label: 'العربية', native: 'Arabic' },
|
||||
{ code: 'es', label: 'Español', native: 'Spanish' },
|
||||
{ code: 'fr', label: 'Français', native: 'French' },
|
||||
{ code: 'de', label: 'Deutsch', native: 'German' },
|
||||
{ code: 'it', label: 'Italiano', native: 'Italian' },
|
||||
{ code: 'pt', label: 'Português', native: 'Portuguese' },
|
||||
{ code: 'ru', label: 'Русский', native: 'Russian' },
|
||||
{ code: 'tr', label: 'Türkçe', native: 'Turkish' },
|
||||
{ code: 'ja', label: '日本語', native: 'Japanese' },
|
||||
{ code: 'ko', label: '한국어', native: 'Korean' },
|
||||
{ code: 'zh', label: '中文', native: 'Chinese' },
|
||||
{ code: 'hi', label: 'हिन्दी', native: 'Hindi' },
|
||||
{ code: 'he', label: 'עברית', native: 'Hebrew' },
|
||||
{ code: 'id', label: 'Bahasa Indonesia', native: 'Indonesian' },
|
||||
{ code: 'nl', label: 'Nederlands', native: 'Dutch' },
|
||||
{ code: 'sv', label: 'Svenska', native: 'Swedish' },
|
||||
{ code: 'no', label: 'Norsk', native: 'Norwegian' },
|
||||
{ code: 'da', label: 'Dansk', native: 'Danish' },
|
||||
{ code: 'fi', label: 'Suomi', native: 'Finnish' },
|
||||
{ code: 'pl', label: 'Polski', native: 'Polish' },
|
||||
{ code: 'cs', label: 'Čeština', native: 'Czech' },
|
||||
{ code: 'ro', label: 'Română', native: 'Romanian' },
|
||||
{ code: 'uk', label: 'Українська', native: 'Ukrainian' },
|
||||
{ code: 'vi', label: 'Tiếng Việt', native: 'Vietnamese' },
|
||||
{ code: 'th', label: 'ไทย', native: 'Thai' },
|
||||
];
|
||||
|
||||
const filteredLanguages = languages.filter(({ label, code, native }) =>
|
||||
(languageSearch || '').length === 0 ||
|
||||
label.toLowerCase().includes(languageSearch.toLowerCase()) ||
|
||||
native.toLowerCase().includes(languageSearch.toLowerCase()) ||
|
||||
code.toLowerCase().includes(languageSearch.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredLanguages.map(({ code, label, native }) => (
|
||||
<TouchableOpacity
|
||||
key={code}
|
||||
onPress={() => { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }}
|
||||
style={[
|
||||
styles.languageItem,
|
||||
settings.tmdbLanguagePreference === code && styles.selectedLanguageItem
|
||||
]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.languageContent}>
|
||||
<View style={styles.languageInfo}>
|
||||
<Text style={[
|
||||
styles.languageName,
|
||||
settings.tmdbLanguagePreference === code && styles.selectedLanguageName,
|
||||
{
|
||||
color: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.text,
|
||||
}
|
||||
]}>
|
||||
{native}
|
||||
</Text>
|
||||
<Text style={[
|
||||
styles.languageCode,
|
||||
settings.tmdbLanguagePreference === code && styles.selectedLanguageCode,
|
||||
{
|
||||
color: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.mediumEmphasis,
|
||||
}
|
||||
]}>
|
||||
{label} • {code.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
{settings.tmdbLanguagePreference === code && (
|
||||
<View style={styles.checkmarkContainer}>
|
||||
<MaterialIcons name="check-circle" size={24} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
{languageSearch.length > 0 && filteredLanguages.length === 0 && (
|
||||
<View style={styles.noResultsContainer}>
|
||||
<MaterialIcons name="search-off" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.noResultsText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
No languages found for "{languageSearch}"
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setLanguageSearch('')}
|
||||
style={[styles.clearSearchButton, { backgroundColor: currentTheme.colors.elevation1 }]}
|
||||
>
|
||||
<Text style={[styles.clearSearchButtonText, { color: currentTheme.colors.primary }]}>Clear search</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setLanguagePickerVisible(false)}
|
||||
style={styles.cancelButton}
|
||||
>
|
||||
<Text style={[styles.cancelButtonText, { color: currentTheme.colors.text }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => setLanguagePickerVisible(false)}
|
||||
style={[styles.doneButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
>
|
||||
<Text style={[styles.doneButtonText, { color: currentTheme.colors.white }]}>Done</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
</ScrollView>
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
|
|
@ -502,73 +771,86 @@ const styles = StyleSheet.create({
|
|||
paddingHorizontal: 16,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
switchCard: {
|
||||
sectionCard: {
|
||||
borderRadius: 16,
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 20,
|
||||
padding: 20,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
switchTextContainer: {
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
marginLeft: 8,
|
||||
},
|
||||
sectionDescription: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
},
|
||||
settingTextContainer: {
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
switchTitle: {
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
switchDescription: {
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
opacity: 0.8,
|
||||
},
|
||||
statusCard: {
|
||||
flexDirection: 'row',
|
||||
borderRadius: 16,
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
languageButton: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statusIconContainer: {
|
||||
marginRight: 12,
|
||||
},
|
||||
statusTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
statusTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statusDescription: {
|
||||
languageButtonText: {
|
||||
fontSize: 14,
|
||||
opacity: 0.8,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
marginVertical: 16,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginLeft: 8,
|
||||
},
|
||||
apiKeyContainer: {
|
||||
marginTop: 16,
|
||||
},
|
||||
infoContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginTop: 16,
|
||||
padding: 12,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 8,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
|
@ -600,14 +882,6 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
clearButton: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
marginRight: 0,
|
||||
marginLeft: 8,
|
||||
flex: 0,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
buttonText: {
|
||||
fontWeight: '600',
|
||||
fontSize: 15,
|
||||
|
|
@ -640,27 +914,204 @@ const styles = StyleSheet.create({
|
|||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
infoCard: {
|
||||
borderRadius: 16,
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
infoIcon: {
|
||||
marginRight: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
flex: 1,
|
||||
lineHeight: 20,
|
||||
opacity: 0.8,
|
||||
marginLeft: 8,
|
||||
},
|
||||
clearButton: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
marginRight: 0,
|
||||
marginLeft: 8,
|
||||
flex: 0,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
|
||||
// Modal Styles
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
maxHeight: '85%',
|
||||
minHeight: '70%', // Increased minimum height
|
||||
flex: 1,
|
||||
},
|
||||
modalHeader: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
dragHandle: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
marginBottom: 12,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginBottom: 4,
|
||||
},
|
||||
modalSubtitle: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
searchSection: {
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
searchIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
searchClearButton: {
|
||||
padding: 4,
|
||||
marginLeft: 8,
|
||||
},
|
||||
popularSection: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
searchResultsTitle: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
popularChips: {
|
||||
paddingVertical: 2,
|
||||
},
|
||||
popularChip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
marginRight: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
selectedChip: {
|
||||
// Border color handled by inline styles
|
||||
},
|
||||
popularChipText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
selectedChipText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
languagesSection: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
languageList: {
|
||||
flex: 1,
|
||||
},
|
||||
languageItem: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 4,
|
||||
minHeight: 60,
|
||||
},
|
||||
selectedLanguageItem: {
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.15)',
|
||||
},
|
||||
languageContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
languageInfo: {
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
languageName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 2,
|
||||
},
|
||||
selectedLanguageName: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
languageCode: {
|
||||
fontSize: 12,
|
||||
},
|
||||
selectedLanguageCode: {
|
||||
},
|
||||
checkmarkContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
noResultsContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 40,
|
||||
},
|
||||
noResultsText: {
|
||||
fontSize: 16,
|
||||
marginTop: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
clearSearchButton: {
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
},
|
||||
clearSearchButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 20,
|
||||
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||
gap: 12,
|
||||
},
|
||||
cancelButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
doneButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
},
|
||||
doneButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
406
src/services/hybridCacheService.ts
Normal file
406
src/services/hybridCacheService.ts
Normal 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;
|
||||
425
src/services/localScraperCacheService.ts
Normal file
425
src/services/localScraperCacheService.ts
Normal 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;
|
||||
|
|
@ -4,6 +4,8 @@ import { Platform } from 'react-native';
|
|||
import { logger } from '../utils/logger';
|
||||
import { Stream } from '../types/streams';
|
||||
import { cacheService } from './cacheService';
|
||||
import { localScraperCacheService } from './localScraperCacheService';
|
||||
import { hybridCacheService } from './hybridCacheService';
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
// Types for local scrapers
|
||||
|
|
@ -34,6 +36,7 @@ export interface ScraperInfo {
|
|||
supportedFormats?: string[];
|
||||
repositoryId?: string; // Which repository this scraper came from
|
||||
supportsExternalPlayer?: boolean; // Whether this scraper supports external players
|
||||
limited?: boolean; // Whether this scraper has limited functionality
|
||||
}
|
||||
|
||||
export interface RepositoryInfo {
|
||||
|
|
@ -81,6 +84,8 @@ class LocalScraperService {
|
|||
private autoRefreshCompleted: boolean = false;
|
||||
private isRefreshing: boolean = false;
|
||||
private scraperSettingsCache: Record<string, any> | null = null;
|
||||
// Single-flight map to prevent duplicate concurrent runs per scraper+title
|
||||
private inFlightByKey: Map<string, Promise<LocalScraperResult[]>> = new Map();
|
||||
|
||||
private constructor() {
|
||||
this.initialize();
|
||||
|
|
@ -857,7 +862,7 @@ class LocalScraperService {
|
|||
}
|
||||
}
|
||||
|
||||
// Execute scrapers for streams
|
||||
// Execute scrapers for streams with caching
|
||||
async getStreams(type: string, tmdbId: string, season?: number, episode?: number, callback?: ScraperCallback): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
|
|
@ -874,23 +879,61 @@ class LocalScraperService {
|
|||
logger.log('[LocalScraperService] No enabled scrapers found for type:', type);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current user settings for enabled scrapers
|
||||
const userSettings = await this.getUserScraperSettings();
|
||||
|
||||
// Check cache for existing results (hybrid: global first, then local)
|
||||
const { validResults, expiredScrapers, allExpired, source } = await hybridCacheService.getCachedResults(type, tmdbId, season, episode, userSettings);
|
||||
|
||||
logger.log('[LocalScraperService] Executing', enabledScrapers.length, 'scrapers for', type, tmdbId);
|
||||
|
||||
// Execute each scraper
|
||||
for (const scraper of enabledScrapers) {
|
||||
this.executeScraper(scraper, type, tmdbId, season, episode, callback);
|
||||
// Immediately return cached results for valid scrapers
|
||||
if (validResults.length > 0) {
|
||||
logger.log(`[LocalScraperService] Returning ${validResults.length} cached results for ${type}:${tmdbId} (source: ${source})`);
|
||||
|
||||
for (const cachedResult of validResults) {
|
||||
if (cachedResult.success && cachedResult.streams.length > 0) {
|
||||
// Streams are already in the correct format, just pass them through
|
||||
if (callback) {
|
||||
callback(cachedResult.streams, cachedResult.scraperId, cachedResult.scraperName, null);
|
||||
}
|
||||
} else if (callback) {
|
||||
// Return error for failed cached results
|
||||
const error = cachedResult.error ? new Error(cachedResult.error) : new Error('Scraper failed');
|
||||
callback(null, cachedResult.scraperId, cachedResult.scraperName, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which scrapers need to be re-run
|
||||
const scrapersToRerun = enabledScrapers.filter(scraper =>
|
||||
expiredScrapers.includes(scraper.id) || !validResults.some(r => r.scraperId === scraper.id)
|
||||
);
|
||||
|
||||
if (scrapersToRerun.length === 0) {
|
||||
logger.log('[LocalScraperService] All scrapers have valid cached results');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.log(`[LocalScraperService] Re-running ${scrapersToRerun.length} scrapers (${expiredScrapers.length} expired, ${scrapersToRerun.length - expiredScrapers.length} not cached) for ${type}:${tmdbId}`);
|
||||
|
||||
// Generate a lightweight request id for tracing
|
||||
const requestId = `rs_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Execute only scrapers that need to be re-run
|
||||
for (const scraper of scrapersToRerun) {
|
||||
this.executeScraperWithCaching(scraper, type, tmdbId, season, episode, callback, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute individual scraper
|
||||
private async executeScraper(
|
||||
// Execute individual scraper with caching
|
||||
private async executeScraperWithCaching(
|
||||
scraper: ScraperInfo,
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
callback?: ScraperCallback
|
||||
callback?: ScraperCallback,
|
||||
requestId?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const code = this.scraperCode.get(scraper.id);
|
||||
|
|
@ -898,38 +941,90 @@ class LocalScraperService {
|
|||
throw new Error(`No code found for scraper ${scraper.id}`);
|
||||
}
|
||||
|
||||
// Skip verbose logging to reduce CPU load
|
||||
|
||||
// Load per-scraper settings
|
||||
const scraperSettings = await this.getScraperSettings(scraper.id);
|
||||
|
||||
// Create a sandboxed execution environment
|
||||
const results = await this.executeSandboxed(code, {
|
||||
tmdbId,
|
||||
mediaType: type,
|
||||
season,
|
||||
episode,
|
||||
scraperId: scraper.id,
|
||||
settings: scraperSettings
|
||||
});
|
||||
// Build single-flight key
|
||||
const flightKey = `${scraper.id}|${type}|${tmdbId}|${season ?? ''}|${episode ?? ''}`;
|
||||
|
||||
// Create a sandboxed execution environment with single-flight coalescing
|
||||
let promise: Promise<LocalScraperResult[]>;
|
||||
if (this.inFlightByKey.has(flightKey)) {
|
||||
promise = this.inFlightByKey.get(flightKey)!;
|
||||
} else {
|
||||
promise = this.executeSandboxed(code, {
|
||||
tmdbId,
|
||||
mediaType: type,
|
||||
season,
|
||||
episode,
|
||||
scraperId: scraper.id,
|
||||
settings: scraperSettings,
|
||||
requestId
|
||||
});
|
||||
this.inFlightByKey.set(flightKey, promise);
|
||||
// Clean up after settle; guard against races
|
||||
promise.finally(() => {
|
||||
const current = this.inFlightByKey.get(flightKey);
|
||||
if (current === promise) this.inFlightByKey.delete(flightKey);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const results = await promise;
|
||||
|
||||
// Convert results to Nuvio Stream format
|
||||
const streams = this.convertToStreams(results, scraper);
|
||||
|
||||
// Cache the successful result (hybrid: both local and global)
|
||||
await hybridCacheService.cacheScraperResult(
|
||||
type,
|
||||
tmdbId,
|
||||
scraper.id,
|
||||
scraper.name,
|
||||
streams,
|
||||
null,
|
||||
season,
|
||||
episode
|
||||
);
|
||||
|
||||
if (callback) {
|
||||
callback(streams, scraper.id, scraper.name, null);
|
||||
}
|
||||
|
||||
// Skip verbose logging to reduce CPU load
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperService] Scraper', scraper.name, 'failed:', error);
|
||||
|
||||
// Cache the failed result (hybrid: both local and global)
|
||||
await hybridCacheService.cacheScraperResult(
|
||||
type,
|
||||
tmdbId,
|
||||
scraper.id,
|
||||
scraper.name,
|
||||
null,
|
||||
error as Error,
|
||||
season,
|
||||
episode
|
||||
);
|
||||
|
||||
if (callback) {
|
||||
callback(null, scraper.id, scraper.name, error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute individual scraper (legacy method - kept for compatibility)
|
||||
private async executeScraper(
|
||||
scraper: ScraperInfo,
|
||||
type: string,
|
||||
tmdbId: string,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
callback?: ScraperCallback,
|
||||
requestId?: string
|
||||
): Promise<void> {
|
||||
// Delegate to the caching version
|
||||
return this.executeScraperWithCaching(scraper, type, tmdbId, season, episode, callback, requestId);
|
||||
}
|
||||
|
||||
// Execute scraper code in sandboxed environment
|
||||
private async executeSandboxed(code: string, params: any): Promise<LocalScraperResult[]> {
|
||||
// This is a simplified sandbox - in production, you'd want more security
|
||||
|
|
@ -1056,7 +1151,7 @@ class LocalScraperService {
|
|||
...options.headers
|
||||
},
|
||||
data: options.body,
|
||||
timeout: 30000,
|
||||
timeout: 60000,
|
||||
validateStatus: () => true // Don't throw on HTTP error status codes
|
||||
};
|
||||
|
||||
|
|
@ -1262,6 +1357,84 @@ class LocalScraperService {
|
|||
await this.ensureInitialized();
|
||||
return Array.from(this.installedScrapers.values()).some(scraper => scraper.enabled);
|
||||
}
|
||||
|
||||
// Get current user scraper settings for cache filtering
|
||||
private async getUserScraperSettings(): Promise<{ enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }> {
|
||||
return this.getUserScraperSettingsWithOverride();
|
||||
}
|
||||
|
||||
// Get user scraper settings (can be overridden for testing or external calls)
|
||||
async getUserScraperSettingsWithOverride(overrideSettings?: { enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }): Promise<{ enableLocalScrapers?: boolean; enabledScrapers?: Set<string> }> {
|
||||
try {
|
||||
// If override settings are provided, use them
|
||||
if (overrideSettings) {
|
||||
return {
|
||||
enableLocalScrapers: overrideSettings.enableLocalScrapers,
|
||||
enabledScrapers: overrideSettings.enabledScrapers
|
||||
};
|
||||
}
|
||||
|
||||
// Get user settings from AsyncStorage
|
||||
const settingsData = await AsyncStorage.getItem('app_settings');
|
||||
const settings = settingsData ? JSON.parse(settingsData) : {};
|
||||
|
||||
// Get enabled scrapers based on current user settings
|
||||
const enabledScrapers = new Set<string>();
|
||||
const installedScrapers = Array.from(this.installedScrapers.values());
|
||||
|
||||
for (const scraper of installedScrapers) {
|
||||
if (scraper.enabled && settings.enableLocalScrapers) {
|
||||
enabledScrapers.add(scraper.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enableLocalScrapers: settings.enableLocalScrapers,
|
||||
enabledScrapers: enabledScrapers.size > 0 ? enabledScrapers : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperService] Error getting user scraper settings:', error);
|
||||
return { enableLocalScrapers: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Cache management methods (hybrid: local + global)
|
||||
async clearScraperCache(): Promise<void> {
|
||||
await hybridCacheService.clearAllCache();
|
||||
logger.log('[LocalScraperService] Cleared all scraper cache (local + global)');
|
||||
}
|
||||
|
||||
async invalidateScraperCache(scraperId: string): Promise<void> {
|
||||
await hybridCacheService.invalidateScraper(scraperId);
|
||||
logger.log('[LocalScraperService] Invalidated cache for scraper:', scraperId);
|
||||
}
|
||||
|
||||
async invalidateContentCache(type: string, tmdbId: string, season?: number, episode?: number): Promise<void> {
|
||||
await hybridCacheService.invalidateContent(type, tmdbId, season, episode);
|
||||
logger.log('[LocalScraperService] Invalidated cache for content:', `${type}:${tmdbId}`);
|
||||
}
|
||||
|
||||
async getCacheStats(): Promise<{
|
||||
local: {
|
||||
totalEntries: number;
|
||||
totalSize: number;
|
||||
oldestEntry: number | null;
|
||||
newestEntry: number | null;
|
||||
};
|
||||
global: {
|
||||
totalEntries: number;
|
||||
totalSize: number;
|
||||
oldestEntry: number | null;
|
||||
newestEntry: number | null;
|
||||
hitRate: number;
|
||||
};
|
||||
combined: {
|
||||
totalEntries: number;
|
||||
hitRate: number;
|
||||
};
|
||||
}> {
|
||||
return await hybridCacheService.getCacheStats();
|
||||
}
|
||||
}
|
||||
|
||||
export const localScraperService = LocalScraperService.getInstance();
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
453
src/services/supabaseGlobalCacheService.ts
Normal file
453
src/services/supabaseGlobalCacheService.ts
Normal 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;
|
||||
|
|
@ -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
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue