diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 185a1ae..0790fb4 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -110,7 +110,7 @@ interface UseMetadataReturn { } 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 +421,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 +488,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 +593,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 +735,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 @@ -1242,8 +1318,17 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat }, [error, loadAttempts]); useEffect(() => { + if (!settingsLoaded) return; 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(() => { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index f782b5b..030d8ae 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/screens/LogoSourceSettings.tsx b/src/screens/LogoSourceSettings.tsx index 2f5b5d0..70c1a3e 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) => ( { 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,311 @@ 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)} + > + + + + Select Language + + + + + setLanguageSearch('')} style={{ marginLeft: 8, paddingHorizontal: 12, paddingVertical: 10, borderRadius: 10, backgroundColor: currentTheme.colors.elevation1 }}> + Clear + + + {/* Most used quick chips */} + + {['en','ar','es','fr','de','tr'].map(code => ( + { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }} style={{ paddingHorizontal: 10, paddingVertical: 6, backgroundColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.elevation1, borderRadius: 999, marginRight: 8 }}> + {code.toUpperCase()} + + ))} + + + {[ + { code: 'en', label: 'English' }, + { code: 'ar', label: 'Arabic' }, + { code: 'es', label: 'Spanish' }, + { code: 'fr', label: 'French' }, + { code: 'de', label: 'German' }, + { code: 'it', label: 'Italian' }, + { code: 'pt', label: 'Portuguese' }, + { code: 'ru', label: 'Russian' }, + { code: 'tr', label: 'Turkish' }, + { code: 'ja', label: 'Japanese' }, + { code: 'ko', label: 'Korean' }, + { code: 'zh', label: 'Chinese' }, + { code: 'hi', label: 'Hindi' }, + { code: 'he', label: 'Hebrew' }, + { code: 'id', label: 'Indonesian' }, + { code: 'nl', label: 'Dutch' }, + { code: 'sv', label: 'Swedish' }, + { code: 'no', label: 'Norwegian' }, + { code: 'da', label: 'Danish' }, + { code: 'fi', label: 'Finnish' }, + { code: 'pl', label: 'Polish' }, + { code: 'cs', label: 'Czech' }, + { code: 'ro', label: 'Romanian' }, + { code: 'uk', label: 'Ukrainian' }, + { code: 'vi', label: 'Vietnamese' }, + { code: 'th', label: 'Thai' }, + ] + .filter(({ label, code }) => + (languageSearch || '').length === 0 || + label.toLowerCase().includes(languageSearch.toLowerCase()) || code.toLowerCase().includes(languageSearch.toLowerCase()) + ) + .map(({ code, label }) => ( + { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }} + style={{ paddingVertical: 12, paddingHorizontal: 6, borderRadius: 10, backgroundColor: settings.tmdbLanguagePreference === code ? currentTheme.colors.elevation1 : 'transparent', marginBottom: 4 }} + activeOpacity={0.8} + > + + + {label} ({code.toUpperCase()}) + + {settings.tmdbLanguagePreference === code && ( + + )} + + + ))} + + setLanguagePickerVisible(false)} style={{ marginTop: 12, paddingVertical: 12, alignItems: 'center', borderRadius: 10, backgroundColor: currentTheme.colors.primary }}> + Done + + + + { + 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 }), });