diff --git a/.gitignore b/.gitignore index bbb0b263..86f4745b 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ src/screens/xavio.md /KSPlayer /exobase ffmpegreadme.md +toast.md diff --git a/package-lock.json b/package-lock.json index f36b7eea..b4685616 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 99c31eea..a9931c03 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index a63dd2c4..71bac4f9 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -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); diff --git a/src/components/home/DropUpMenu.tsx b/src/components/home/DropUpMenu.tsx index d84ca623..bbe9c044 100644 --- a/src/components/home/DropUpMenu.tsx +++ b/src/components/home/DropUpMenu.tsx @@ -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 ( diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 9f95c57c..2f5179e8 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -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); diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 185a1ae4..65eb1ae7 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -107,10 +107,32 @@ interface UseMetadataReturn { imdbId: string | null; scraperStatuses: ScraperStatus[]; activeFetchingScrapers: string[]; + clearScraperCache: () => Promise; + invalidateScraperCache: (scraperId: string) => Promise; + invalidateContentCache: (type: string, tmdbId: string, season?: number, episode?: number) => Promise; + 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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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, }; }; \ No newline at end of file diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index f782b5b4..030d8aec 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -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'; diff --git a/src/hooks/useUpdatePopup.ts b/src/hooks/useUpdatePopup.ts index 9885190b..87ba6c78 100644 --- a/src/hooks/useUpdatePopup.ts +++ b/src/hooks/useUpdatePopup.ts @@ -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); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 25924778..425e92f6 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -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 - + {/* Global toast customization using ThemeContext */} + ( + + {props.text1} + {props.text2 ? ( + {props.text2} + ) : null} + + ), + success: (props: any) => ( + + {props.text1} + {props.text2 ? ( + {props.text2} + ) : null} + + ), + error: (props: any) => ( + + {props.text1} + {props.text2 ? ( + {props.text2} + ) : null} + + ), + }} + /> ); }; diff --git a/src/screens/AuthScreen.tsx b/src/screens/AuthScreen.tsx index a5e63d6f..ba08816e 100644 --- a/src/screens/AuthScreen.tsx +++ b/src/screens/AuthScreen.tsx @@ -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 diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 293aed72..4550a53d 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -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({ }, }); -export default React.memo(HomeScreen); \ No newline at end of file +import { DeviceEventEmitter } from 'react-native'; + +const HomeScreenWithFocusSync = (props: any) => { + const navigation = useNavigation>(); + useEffect(() => { + const unsubscribe = navigation.addListener('focus', () => { + DeviceEventEmitter.emit('watchedStatusChanged'); + }); + return () => unsubscribe(); + }, [navigation]); + return ; +}; + +export default React.memo(HomeScreenWithFocusSync); \ No newline at end of file diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 71a6848c..6bb8f784 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -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(null); + // DropUpMenu state + const [menuVisible, setMenuVisible] = useState(false); + const [selectedItem, setSelectedItem] = useState(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 = () => { navigation.navigate('Metadata', { id: item.id, type: item.type })} + onLongPress={() => { + setSelectedItem(item); + setMenuVisible(true); + }} activeOpacity={0.7} > - + - + {item.watched && ( + + + + )} {item.progress !== undefined && item.progress < 1 && ( { )} - + {item.name} @@ -932,6 +987,62 @@ const LibraryScreen = () => { {showTraktContent ? renderTraktContent() : renderContent()} + + {/* DropUpMenu integration */} + {selectedItem && ( + 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; + } + }} + /> + )} ); }; @@ -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, }, diff --git a/src/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 2f5b5d08..70c1a3e7 100644 --- a/src/screens/LogoSourceSettings.tsx +++ b/src/screens/LogoSourceSettings.tsx @@ -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(null); const [metahubBanner, setMetahubBanner] = useState(null); const [loadingLogos, setLoadingLogos] = useState(true); + // Track which language the preview is actually using and if it is a fallback + const [previewLanguage, setPreviewLanguage] = useState(''); + const [isPreviewFallback, setIsPreviewFallback] = useState(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 = () => { Example: {renderLogoExample(tmdbLogo, tmdbBanner, loadingLogos)} + + {`Preview language: ${(previewLanguage || '').toUpperCase() || 'N/A'}${isPreviewFallback ? ' (fallback)' : ''}`} + {selectedShow.name} logo from TMDB {/* TMDB Language Selector */} - {uniqueTmdbLanguages.length > 1 && ( + {true && ( Logo Language - 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). { 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([...uniqueTmdbLanguages, ...COMMON_TMDB_LANGUAGES])).map((langCode) => ( = ({ 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 = () => { About Plugins - 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. + + + Note: Providers marked as "Limited" depend on external APIs that may stop working without notice. + diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 0daad606..0d959f41 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -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(null); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); - + // DropUpMenu state + const [menuVisible, setMenuVisible] = useState(false); + const [selectedItem, setSelectedItem] = useState(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 ( { 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} > + }]}> - - - {item.type === 'movie' ? 'MOVIE' : 'SERIES'} - - + {/* Bookmark and watched icons top right, bookmark to the left of watched */} + {inLibrary && ( + + + + )} + {watched && ( + + + + )} {item.imdbRating && ( - + {item.imdbRating} @@ -482,7 +547,7 @@ const SearchScreen = () => { {item.name} {item.year && ( - + {item.year} )} @@ -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 ( { backgroundColor="transparent" translucent /> - {/* Fixed position header background to prevent shifts */} - {/* Header Section with proper top spacing */} @@ -580,7 +654,6 @@ const SearchScreen = () => { - {/* Content Container */} {searching ? ( @@ -600,10 +673,10 @@ const SearchScreen = () => { size={64} color={currentTheme.colors.lightGray} /> - + Keep typing... - + Type at least 2 characters to search @@ -617,10 +690,10 @@ const SearchScreen = () => { size={64} color={currentTheme.colors.lightGray} /> - + No results found - + Try different keywords or check your spelling @@ -634,48 +707,110 @@ const SearchScreen = () => { showsVerticalScrollIndicator={false} > {!query.trim() && renderRecentSearches()} - {movieResults.length > 0 && ( - + Movies ({movieResults.length}) ( + + )} keyExtractor={item => `movie-${item.id}`} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.horizontalListContent} + extraData={refreshFlag} /> )} - {seriesResults.length > 0 && ( - + TV Shows ({seriesResults.length}) ( + + )} keyExtractor={item => `series-${item.id}`} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.horizontalListContent} + extraData={refreshFlag} /> )} - )} + {/* DropUpMenu integration for search results */} + {selectedItem && ( + 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; + } + }} + /> + )} ); @@ -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; \ No newline at end of file diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 43fd7fe0..77730d7d 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -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(); + + // 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; } }; diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx index b505a85b..b6bc4467 100644 --- a/src/screens/TMDBSettingsScreen.tsx +++ b/src/screens/TMDBSettingsScreen.tsx @@ -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 = () => { - - - - Enrich Metadata with TMDb - When enabled, the app augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback. Disable to strictly use addon metadata only. + {/* Metadata Enrichment Section */} + + + + Metadata Enrichment - 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)'} - /> - - - - Use Custom TMDb API Key - - 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. - - - - + + Enhance your content metadata with TMDb data for better details and information. + - {useCustomKey && ( - <> - - - - - {isKeySet ? "API Key Active" : "API Key Required"} - - - {isKeySet - ? "Your custom TMDb API key is set and active." - : "Add your TMDb API key below."} - - - - - - API Key - - { - 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)} - /> - - - - - - - - Save API Key - - - {isKeySet && ( - - Clear - - )} - - - {testResult && ( - - - - {testResult.message} - - - )} - - - - - How to get a TMDb API key? - - - - - - - - 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. + + + Enable Enrichment + + Augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback. - - )} - - {!useCustomKey && ( - - - - 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. - + 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)'} + /> - )} + + {settings.enrichMetadataWithTMDB && ( + <> + + + + + Localized Text + + Fetch titles and descriptions in your preferred language from TMDb. + + + 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)'} + /> + + + {settings.useTmdbLocalizedMetadata && ( + <> + + + + + Language + + Current: {(settings.tmdbLanguagePreference || 'en').toUpperCase()} + + + setLanguagePickerVisible(true)} + style={[styles.languageButton, { backgroundColor: currentTheme.colors.primary }]} + > + Change + + + + )} + + )} + + + {/* API Configuration Section */} + + + + API Configuration + + + Configure your TMDb API access for enhanced functionality. + + + + + Custom API Key + + Use your own TMDb API key for better performance and dedicated rate limits. + + + + + + {useCustomKey && ( + <> + + + {/* API Key Status */} + + + + {isKeySet ? "Custom API key active" : "API key required"} + + + + {/* API Key Input */} + + + { + 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)} + /> + + + + + + + + Save + + + {isKeySet && ( + + Clear + + )} + + + {testResult && ( + + + + {testResult.message} + + + )} + + + + + How to get a TMDb API key? + + + + + )} + + {!useCustomKey && ( + + + + Currently using built-in API key. Consider using your own key for better performance. + + + )} + + + {/* Language Picker Modal */} + setLanguagePickerVisible(false)} + > + setLanguagePickerVisible(false)}> + + + + {/* Header */} + + + Choose Language + Select your preferred language for TMDb content + + + {/* Search Section */} + + + + + {languageSearch.length > 0 && ( + setLanguageSearch('')} style={styles.searchClearButton}> + + + )} + + + + {/* Popular Languages */} + {languageSearch.length === 0 && ( + + Popular + + {[ + { 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 }) => ( + { 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)', + } + ]} + > + + {label} + + + ))} + + + )} + + {/* All Languages */} + + 0 && styles.searchResultsTitle, + { color: languageSearch.length > 0 ? currentTheme.colors.text : currentTheme.colors.mediumEmphasis } + ]}> + {languageSearch.length > 0 ? 'Search Results' : 'All Languages'} + + + + {(() => { + 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 }) => ( + { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }} + style={[ + styles.languageItem, + settings.tmdbLanguagePreference === code && styles.selectedLanguageItem + ]} + activeOpacity={0.7} + > + + + + {native} + + + {label} • {code.toUpperCase()} + + + {settings.tmdbLanguagePreference === code && ( + + + + )} + + + ))} + {languageSearch.length > 0 && filteredLanguages.length === 0 && ( + + + + No languages found for "{languageSearch}" + + setLanguageSearch('')} + style={[styles.clearSearchButton, { backgroundColor: currentTheme.colors.elevation1 }]} + > + Clear search + + + )} + + ); + })()} + + + + {/* Footer Actions */} + + setLanguagePickerVisible(false)} + style={styles.cancelButton} + > + Cancel + + setLanguagePickerVisible(false)} + style={[styles.doneButton, { backgroundColor: currentTheme.colors.primary }]} + > + Done + + + + + + + { // 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 {} } }, []); diff --git a/src/services/hybridCacheService.ts b/src/services/hybridCacheService.ts new file mode 100644 index 00000000..86d07801 --- /dev/null +++ b/src/services/hybridCacheService.ts @@ -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; + 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 } + ): Promise { + 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 { + 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 { + 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 } + ): Promise { + 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 } + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/src/services/localScraperCacheService.ts b/src/services/localScraperCacheService.ts new file mode 100644 index 00000000..67ccf1fd --- /dev/null +++ b/src/services/localScraperCacheService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/src/services/localScraperService.ts b/src/services/localScraperService.ts index fa63e4f6..d0fbd8ac 100644 --- a/src/services/localScraperService.ts +++ b/src/services/localScraperService.ts @@ -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 | null = null; + // Single-flight map to prevent duplicate concurrent runs per scraper+title + private inFlightByKey: Map> = 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 { 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 { 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; + 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 { + // 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 { // 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 }> { + return this.getUserScraperSettingsWithOverride(); + } + + // Get user scraper settings (can be overridden for testing or external calls) + async getUserScraperSettingsWithOverride(overrideSettings?: { enableLocalScrapers?: boolean; enabledScrapers?: Set }): Promise<{ enableLocalScrapers?: boolean; enabledScrapers?: Set }> { + 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(); + 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 { + await hybridCacheService.clearAllCache(); + logger.log('[LocalScraperService] Cleared all scraper cache (local + global)'); + } + + async invalidateScraperCache(scraperId: string): Promise { + await hybridCacheService.invalidateScraper(scraperId); + logger.log('[LocalScraperService] Invalidated cache for scraper:', scraperId); + } + + async invalidateContentCache(type: string, tmdbId: string, season?: number, episode?: number): Promise { + 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(); diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 5bd5326a..9be32c53 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -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`); diff --git a/src/services/supabaseGlobalCacheService.ts b/src/services/supabaseGlobalCacheService.ts new file mode 100644 index 00000000..a9146847 --- /dev/null +++ b/src/services/supabaseGlobalCacheService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index e1b2247d..80de8a1d 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -160,12 +160,12 @@ export class TMDBService { /** * Get TV show details by TMDB ID */ - async getTVShowDetails(tmdbId: number): Promise { + async getTVShowDetails(tmdbId: number, language: string = 'en'): Promise { 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 { + async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string, language: string = 'en-US'): Promise { 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 { 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 { + async getRecommendations(type: 'movie' | 'tv', tmdbId: string, language: string = 'en-US'): Promise { 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 { + async getMovieDetails(movieId: string, language: string = 'en'): Promise { 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 }), });