diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index 405208d8..da73b27e 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -28,6 +28,7 @@ import Animated, { } from 'react-native-reanimated'; import { StreamingContent } from '../../services/catalogService'; import { SkeletonFeatured } from './SkeletonLoaders'; +import { isValidMetahubLogo, hasValidLogoFormat } from '../../utils/logoUtils'; interface FeaturedContentProps { featuredContent: StreamingContent | null; @@ -45,6 +46,8 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const [logoUrl, setLogoUrl] = useState(null); const [bannerUrl, setBannerUrl] = useState(null); const prevContentIdRef = useRef(null); + // Add state for tracking logo load errors + const [logoLoadError, setLogoLoadError] = useState(false); // Animation values const posterOpacity = useSharedValue(0); @@ -74,15 +77,37 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat if (imageCache[url]) return true; try { + // For Metahub logos, only do validation if enabled + // Note: Temporarily disable metahub validation until fixed + if (false && url.includes('metahub.space')) { + try { + const isValid = await isValidMetahubLogo(url); + if (!isValid) { + console.warn(`[FeaturedContent] Metahub logo validation failed: ${url}`); + return false; + } + } catch (validationError) { + // If validation fails, still try to load the image + console.warn(`[FeaturedContent] Logo validation error, will try to load anyway: ${url}`, validationError); + } + } + + // Always attempt to prefetch the image regardless of format validation await ExpoImage.prefetch(url); imageCache[url] = true; + console.log(`[FeaturedContent] Successfully preloaded image: ${url}`); return true; } catch (error) { - console.error('Error preloading image:', error); + console.error('[FeaturedContent] Error preloading image:', error); return false; } }; + // Reset logo error state when content changes + useEffect(() => { + setLogoLoadError(false); + }, [featuredContent?.id]); + // Load poster and logo useEffect(() => { if (!featuredContent) return; @@ -124,6 +149,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat duration: 500, easing: Easing.bezier(0.25, 0.1, 0.25, 1) })); + } else { + // If prefetch fails, mark as error to show title text instead + setLogoLoadError(true); + console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${titleLogo}`); } } }; @@ -165,7 +194,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat - {featuredContent.logo ? ( + {featuredContent.logo && !logoLoadError ? ( { + console.warn(`[FeaturedContent] Logo failed to load: ${featuredContent.logo}`); + setLogoLoadError(true); + }} /> ) : ( diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 75fa51a3..246fdfe7 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -56,6 +56,7 @@ import { TMDBService } from '../services/tmdbService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; import { useGenres } from '../contexts/GenreContext'; +import { isValidMetahubLogo, isMetahubUrl, isTmdbUrl } from '../utils/logoUtils'; const { width, height } = Dimensions.get('window'); @@ -264,6 +265,14 @@ const MetadataScreen = () => { episodeId?: string; } | null>(null); + // Add state to track image load errors + const [logoLoadError, setLogoLoadError] = useState(false); + + // Reset logo load error when metadata changes + useEffect(() => { + setLogoLoadError(false); + }, [metadata?.logo]); + // Add wrapper for toggleLibrary that includes haptic feedback const handleToggleLibrary = useCallback(() => { // Trigger appropriate haptic feedback based on action @@ -324,7 +333,7 @@ const MetadataScreen = () => { logger.log(`[MetadataScreen] Attempting to fetch logo from Metahub for ${imdbId}`); - // Test if Metahub logo exists with a HEAD request + // For now, skip detailed validation and just check if URL is accessible try { const response = await fetch(metahubUrl, { method: 'HEAD' }); if (response.ok) { @@ -340,6 +349,8 @@ const MetadataScreen = () => { logo: metahubUrl })); return; // Exit if Metahub logo was found + } else { + logger.warn(`[MetadataScreen] Metahub logo request failed with status ${response.status}`); } } catch (metahubError) { logger.warn(`[MetadataScreen] Failed to fetch logo from Metahub:`, metahubError); @@ -367,8 +378,16 @@ const MetadataScreen = () => { logo: logoUrl })); } else { - logger.warn(`[MetadataScreen] No logo found from either Metahub or TMDB for ${type} (ID: ${id})`); + // If both Metahub and TMDB fail, use the title as text instead of a logo + logger.warn(`[MetadataScreen] No logo found from either Metahub or TMDB for ${type} (ID: ${id}), using title text instead`); + + // Leave logo as null/undefined to trigger fallback to text } + } else { + // If no TMDB ID and Metahub failed, use the title as text instead of a logo + logger.warn(`[MetadataScreen] No logo found for ${type} (ID: ${id}), using title text instead`); + + // Leave logo as null/undefined to trigger fallback to text } } catch (error) { logger.error('[MetadataScreen] Failed to fetch logo from all sources:', { @@ -385,6 +404,7 @@ const MetadataScreen = () => { - Content ID: ${id} - Content Type: ${type} - Logo URL: ${metadata.logo} + - Source: ${isMetahubUrl(metadata.logo) ? 'Metahub' : (isTmdbUrl(metadata.logo) ? 'TMDB' : 'Other')} `); } }, [id, type, metadata, setMetadata, imdbId]); @@ -1077,12 +1097,16 @@ const MetadataScreen = () => { - {metadata.logo ? ( + {metadata.logo && !logoLoadError ? ( { + logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} /> ) : ( {metadata.name} @@ -1120,12 +1144,16 @@ const MetadataScreen = () => { - {metadata.logo ? ( + {metadata.logo && !logoLoadError ? ( { + logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} /> ) : ( {metadata.name} @@ -1181,12 +1209,16 @@ const MetadataScreen = () => { {/* Title */} - {metadata.logo ? ( + {metadata.logo && !logoLoadError ? ( { + logger.warn(`[MetadataScreen] Logo failed to load: ${metadata.logo}`); + setLogoLoadError(true); + }} /> ) : ( {metadata.name} diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 63459f80..4e7591c3 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { View, Text, @@ -14,6 +14,9 @@ import { Dimensions, ScrollView, Animated as RNAnimated, + Pressable, + Platform, + Easing, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -23,9 +26,22 @@ import { catalogService, StreamingContent } from '../services/catalogService'; import { Image } from 'expo-image'; import debounce from 'lodash/debounce'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import Animated, { FadeIn, FadeOut, SlideInRight } from 'react-native-reanimated'; +import Animated, { + FadeIn, + FadeOut, + SlideInRight, + useAnimatedStyle, + useSharedValue, + withTiming, + interpolate, + withSpring, + withDelay, + ZoomIn +} from 'react-native-reanimated'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; +import { BlurView } from 'expo-blur'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; const { width } = Dimensions.get('window'); const HORIZONTAL_ITEM_WIDTH = width * 0.3; @@ -37,6 +53,8 @@ const MAX_RECENT_SEARCHES = 10; const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster'; +const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); + const SkeletonLoader = () => { const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; @@ -91,6 +109,72 @@ const SkeletonLoader = () => { ); }; +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +// Create a simple, elegant animation component +const SimpleSearchAnimation = () => { + // Simple animation values that work reliably + const spinAnim = React.useRef(new RNAnimated.Value(0)).current; + const fadeAnim = React.useRef(new RNAnimated.Value(0)).current; + + React.useEffect(() => { + // Rotation animation + const spin = RNAnimated.loop( + RNAnimated.timing(spinAnim, { + toValue: 1, + duration: 1500, + easing: Easing.linear, + useNativeDriver: true, + }) + ); + + // Fade animation + const fade = RNAnimated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }); + + // Start animations + spin.start(); + fade.start(); + + // Clean up + return () => { + spin.stop(); + }; + }, [spinAnim, fadeAnim]); + + // Simple rotation interpolation + const spin = spinAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'], + }); + + return ( + + + + + + Searching + + + ); +}; + const SearchScreen = () => { const navigation = useNavigation>(); const isDarkMode = true; @@ -100,6 +184,30 @@ const SearchScreen = () => { const [searched, setSearched] = useState(false); const [recentSearches, setRecentSearches] = useState([]); const [showRecent, setShowRecent] = useState(true); + const inputRef = useRef(null); + const insets = useSafeAreaInsets(); + + // Animation values + const searchBarWidth = useSharedValue(width - 32); + const searchBarOpacity = useSharedValue(1); + const backButtonOpacity = useSharedValue(0); + + // Force consistent status bar settings + useEffect(() => { + const applyStatusBarConfig = () => { + StatusBar.setBarStyle('light-content'); + if (Platform.OS === 'android') { + StatusBar.setTranslucent(true); + StatusBar.setBackgroundColor('transparent'); + } + }; + + applyStatusBarConfig(); + + // Re-apply on focus + const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); + return unsubscribe; + }, [navigation]); React.useLayoutEffect(() => { navigation.setOptions({ @@ -111,6 +219,55 @@ const SearchScreen = () => { loadRecentSearches(); }, []); + const animatedSearchBarStyle = useAnimatedStyle(() => { + return { + width: searchBarWidth.value, + opacity: searchBarOpacity.value, + }; + }); + + const animatedBackButtonStyle = useAnimatedStyle(() => { + return { + opacity: backButtonOpacity.value, + transform: [ + { + translateX: interpolate( + backButtonOpacity.value, + [0, 1], + [-20, 0] + ) + } + ] + }; + }); + + const handleSearchFocus = () => { + // Animate search bar when focused + searchBarWidth.value = withTiming(width - 80); + backButtonOpacity.value = withTiming(1); + }; + + const handleSearchBlur = () => { + if (!query) { + // Only animate back if query is empty + searchBarWidth.value = withTiming(width - 32); + backButtonOpacity.value = withTiming(0); + } + }; + + const handleBackPress = () => { + Keyboard.dismiss(); + if (query) { + setQuery(''); + setResults([]); + setSearched(false); + setShowRecent(true); + loadRecentSearches(); + } else { + navigation.goBack(); + } + }; + const loadRecentSearches = async () => { try { const savedSearches = await AsyncStorage.getItem(RECENT_SEARCHES_KEY); @@ -147,7 +304,9 @@ const SearchScreen = () => { try { const searchResults = await catalogService.searchContentCinemeta(searchQuery); setResults(searchResults); - await saveRecentSearch(searchQuery); + if (searchResults.length > 0) { + await saveRecentSearch(searchQuery); + } } catch (error) { logger.error('Search failed:', error); setResults([]); @@ -178,50 +337,66 @@ const SearchScreen = () => { setSearched(false); setShowRecent(true); loadRecentSearches(); + inputRef.current?.focus(); }; const renderRecentSearches = () => { if (!showRecent || recentSearches.length === 0) return null; return ( - - + + Recent Searches {recentSearches.map((search, index) => ( - { setQuery(search); Keyboard.dismiss(); }} + entering={FadeIn.duration(300).delay(index * 50)} > - + {search} - + { + const newRecentSearches = [...recentSearches]; + newRecentSearches.splice(index, 1); + setRecentSearches(newRecentSearches); + AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); + }} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + style={styles.recentSearchDeleteButton} + > + + + ))} - + ); }; - const renderHorizontalItem = ({ item }: { item: StreamingContent }) => { + const renderHorizontalItem = ({ item, index }: { item: StreamingContent, index: number }) => { return ( - { navigation.navigate('Metadata', { id: item.id, type: item.type }); }} + entering={FadeIn.duration(500).delay(index * 100)} + activeOpacity={0.7} > { contentFit="cover" transition={300} /> + + {item.type === 'movie' ? 'MOVIE' : 'SERIES'} + + {item.imdbRating && ( + + + {item.imdbRating} + + )} {item.name} - + {item.year && ( + {item.year} + )} + ); }; @@ -253,148 +440,204 @@ const SearchScreen = () => { return movieResults.length > 0 || seriesResults.length > 0; }, [movieResults, seriesResults]); + const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; + const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; + const headerHeight = headerBaseHeight + topSpacing + 60; + return ( - + - - Search - - - - {query.length > 0 && ( - + + + {/* Header Section with proper top spacing */} + + Search + + + + {query.length > 0 && ( + + + + )} + + + + {/* Content Container */} + + {searching ? ( + + ) : searched && !hasResultsToShow ? ( + - + + No results found + + + Try different keywords or check your spelling + + + ) : ( + + {!query.trim() && renderRecentSearches()} + + {movieResults.length > 0 && ( + + Movies ({movieResults.length}) + `movie-${item.id}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalListContent} + /> + + )} + + {seriesResults.length > 0 && ( + + TV Shows ({seriesResults.length}) + `series-${item.id}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalListContent} + /> + + )} + + )} - - {searching ? ( - - ) : searched && !hasResultsToShow ? ( - - - - No results found - - - Try different keywords or check your spelling - - - ) : ( - - {!query.trim() && renderRecentSearches()} - - {movieResults.length > 0 && ( - - Movies ({movieResults.length}) - `movie-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - - )} - - {seriesResults.length > 0 && ( - - TV Shows ({seriesResults.length}) - `series-${item.id}`} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalListContent} - /> - - )} - - - )} - + ); }; const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: colors.black, + }, + headerBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: colors.black, + zIndex: 1, + }, + contentContainer: { + flex: 1, + backgroundColor: colors.black, + paddingTop: 0, }, header: { - paddingHorizontal: 16, - paddingTop: 40, - paddingBottom: 12, - backgroundColor: colors.black, - gap: 16, + paddingHorizontal: 20, + justifyContent: 'flex-end', + paddingBottom: 8, + backgroundColor: 'transparent', + zIndex: 2, }, headerTitle: { fontSize: 32, fontWeight: '800', color: colors.white, letterSpacing: 0.5, + marginBottom: 12, + }, + searchBarContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 8, + }, + searchBarWrapper: { + flex: 1, }, searchBar: { flexDirection: 'row', alignItems: 'center', - borderRadius: 24, + borderRadius: 12, paddingHorizontal: 16, height: 48, + backgroundColor: colors.darkGray, + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 3.84, + elevation: 5, + }, + backButton: { + marginRight: 10, + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', }, searchIcon: { marginRight: 12, @@ -403,6 +646,7 @@ const styles = StyleSheet.create({ flex: 1, fontSize: 16, height: '100%', + color: colors.white, }, clearButton: { padding: 4, @@ -412,6 +656,7 @@ const styles = StyleSheet.create({ }, scrollViewContent: { paddingBottom: 20, + paddingHorizontal: 0, }, carouselContainer: { marginBottom: 24, @@ -424,7 +669,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, }, horizontalListContent: { - paddingHorizontal: 16, + paddingHorizontal: 12, paddingRight: 8, }, horizontalItem: { @@ -434,10 +679,12 @@ const styles = StyleSheet.create({ horizontalItemPosterContainer: { width: HORIZONTAL_ITEM_WIDTH, height: HORIZONTAL_POSTER_HEIGHT, - borderRadius: 8, + borderRadius: 12, overflow: 'hidden', backgroundColor: colors.darkBackground, marginBottom: 8, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.05)', }, horizontalItemPoster: { width: '100%', @@ -445,19 +692,30 @@ const styles = StyleSheet.create({ }, horizontalItemTitle: { fontSize: 14, - fontWeight: '500', + fontWeight: '600', lineHeight: 18, textAlign: 'left', + color: colors.white, + }, + yearText: { + fontSize: 12, + color: colors.mediumGray, + marginTop: 2, }, recentSearchesContainer: { - paddingHorizontal: 0, + paddingHorizontal: 16, paddingBottom: 16, + paddingTop: 8, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.05)', + marginBottom: 8, }, recentSearchItem: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 16, + marginVertical: 1, }, recentSearchIcon: { marginRight: 12, @@ -465,6 +723,10 @@ const styles = StyleSheet.create({ recentSearchText: { fontSize: 16, flex: 1, + color: colors.white, + }, + recentSearchDeleteButton: { + padding: 4, }, loadingContainer: { flex: 1, @@ -474,6 +736,7 @@ const styles = StyleSheet.create({ loadingText: { marginTop: 16, fontSize: 16, + color: colors.white, }, emptyContainer: { flex: 1, @@ -486,14 +749,20 @@ const styles = StyleSheet.create({ fontWeight: 'bold', marginTop: 16, marginBottom: 8, + color: colors.white, }, emptySubtext: { fontSize: 14, textAlign: 'center', lineHeight: 20, + color: colors.lightGray, }, skeletonContainer: { - padding: 16, + flexDirection: 'row', + flexWrap: 'wrap', + paddingHorizontal: 12, + paddingTop: 16, + justifyContent: 'space-between', }, skeletonVerticalItem: { flexDirection: 'row', @@ -535,6 +804,67 @@ 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: { + color: colors.white, + fontSize: 8, + fontWeight: '700', + }, + ratingContainer: { + position: 'absolute', + bottom: 8, + right: 8, + backgroundColor: 'rgba(0,0,0,0.7)', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 4, + }, + ratingText: { + color: colors.white, + fontSize: 10, + fontWeight: '700', + marginLeft: 2, + }, + simpleAnimationContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + simpleAnimationContent: { + alignItems: 'center', + }, + spinnerContainer: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: colors.primary, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.2, + shadowRadius: 4, + elevation: 3, + }, + simpleAnimationText: { + color: colors.white, + fontSize: 16, + fontWeight: '600', + }, }); export default SearchScreen; \ No newline at end of file diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index a2161966..3a1d2dff 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -375,8 +375,16 @@ export class TMDBService { * Get image URL for TMDB images */ getImageUrl(path: string | null, size: 'original' | 'w500' | 'w300' | 'w185' | 'profile' = 'original'): string | null { - if (!path) return null; - return `https://image.tmdb.org/t/p/${size}${path}`; + if (!path) { + logger.warn(`[TMDBService] Cannot construct image URL from null path`); + return null; + } + + const baseImageUrl = 'https://image.tmdb.org/t/p/'; + const fullUrl = `${baseImageUrl}${size}${path}`; + logger.log(`[TMDBService] Constructed image URL: ${fullUrl}`); + + return fullUrl; } /** @@ -562,6 +570,8 @@ export class TMDBService { */ async getMovieImages(movieId: number | string): Promise { try { + logger.log(`[TMDBService] Fetching movie images for TMDB ID: ${movieId}`); + const response = await axios.get(`${BASE_URL}/movie/${movieId}/images`, { headers: await this.getHeaders(), params: await this.getParams({ @@ -570,6 +580,8 @@ export class TMDBService { }); const images = response.data; + logger.log(`[TMDBService] Retrieved ${images?.logos?.length || 0} logos for movie ID ${movieId}`); + if (images && images.logos && images.logos.length > 0) { // First prioritize English SVG logos const enSvgLogo = images.logos.find((logo: any) => @@ -578,6 +590,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enSvgLogo) { + logger.log(`[TMDBService] Found English SVG logo for movie ID ${movieId}: ${enSvgLogo.file_path}`); return this.getImageUrl(enSvgLogo.file_path); } @@ -588,6 +601,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enPngLogo) { + logger.log(`[TMDBService] Found English PNG logo for movie ID ${movieId}: ${enPngLogo.file_path}`); return this.getImageUrl(enPngLogo.file_path); } @@ -596,6 +610,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enLogo) { + logger.log(`[TMDBService] Found English logo for movie ID ${movieId}: ${enLogo.file_path}`); return this.getImageUrl(enLogo.file_path); } @@ -604,6 +619,7 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.svg') ); if (svgLogo) { + logger.log(`[TMDBService] Found SVG logo for movie ID ${movieId}: ${svgLogo.file_path}`); return this.getImageUrl(svgLogo.file_path); } @@ -612,17 +628,20 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.png') ); if (pngLogo) { + logger.log(`[TMDBService] Found PNG logo for movie ID ${movieId}: ${pngLogo.file_path}`); return this.getImageUrl(pngLogo.file_path); } // Last resort: any logo + logger.log(`[TMDBService] Using first available logo for movie ID ${movieId}: ${images.logos[0].file_path}`); return this.getImageUrl(images.logos[0].file_path); } + logger.warn(`[TMDBService] No logos found for movie ID ${movieId}`); return null; // No logos found } catch (error) { // Log error but don't throw, just return null if fetching images fails - logger.error(`Failed to get movie images for ID ${movieId}:`, error); + logger.error(`[TMDBService] Failed to get movie images for ID ${movieId}:`, error); return null; } } @@ -632,6 +651,8 @@ export class TMDBService { */ async getTvShowImages(showId: number | string): Promise { try { + logger.log(`[TMDBService] Fetching TV show images for TMDB ID: ${showId}`); + const response = await axios.get(`${BASE_URL}/tv/${showId}/images`, { headers: await this.getHeaders(), params: await this.getParams({ @@ -640,6 +661,8 @@ export class TMDBService { }); const images = response.data; + logger.log(`[TMDBService] Retrieved ${images?.logos?.length || 0} logos for TV show ID ${showId}`); + if (images && images.logos && images.logos.length > 0) { // First prioritize English SVG logos const enSvgLogo = images.logos.find((logo: any) => @@ -648,6 +671,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enSvgLogo) { + logger.log(`[TMDBService] Found English SVG logo for TV show ID ${showId}: ${enSvgLogo.file_path}`); return this.getImageUrl(enSvgLogo.file_path); } @@ -658,6 +682,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enPngLogo) { + logger.log(`[TMDBService] Found English PNG logo for TV show ID ${showId}: ${enPngLogo.file_path}`); return this.getImageUrl(enPngLogo.file_path); } @@ -666,6 +691,7 @@ export class TMDBService { logo.iso_639_1 === 'en' ); if (enLogo) { + logger.log(`[TMDBService] Found English logo for TV show ID ${showId}: ${enLogo.file_path}`); return this.getImageUrl(enLogo.file_path); } @@ -674,6 +700,7 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.svg') ); if (svgLogo) { + logger.log(`[TMDBService] Found SVG logo for TV show ID ${showId}: ${svgLogo.file_path}`); return this.getImageUrl(svgLogo.file_path); } @@ -682,17 +709,20 @@ export class TMDBService { logo.file_path && logo.file_path.endsWith('.png') ); if (pngLogo) { + logger.log(`[TMDBService] Found PNG logo for TV show ID ${showId}: ${pngLogo.file_path}`); return this.getImageUrl(pngLogo.file_path); } // Last resort: any logo + logger.log(`[TMDBService] Using first available logo for TV show ID ${showId}: ${images.logos[0].file_path}`); return this.getImageUrl(images.logos[0].file_path); } + logger.warn(`[TMDBService] No logos found for TV show ID ${showId}`); return null; // No logos found } catch (error) { // Log error but don't throw, just return null if fetching images fails - logger.error(`Failed to get TV show images for ID ${showId}:`, error); + logger.error(`[TMDBService] Failed to get TV show images for ID ${showId}:`, error); return null; } } @@ -702,11 +732,21 @@ export class TMDBService { */ async getContentLogo(type: 'movie' | 'tv', id: number | string): Promise { try { - return type === 'movie' + logger.log(`[TMDBService] Getting content logo for ${type} with ID ${id}`); + + const result = type === 'movie' ? await this.getMovieImages(id) : await this.getTvShowImages(id); + + if (result) { + logger.log(`[TMDBService] Successfully retrieved logo for ${type} ID ${id}: ${result}`); + } else { + logger.warn(`[TMDBService] No logo found for ${type} ID ${id}`); + } + + return result; } catch (error) { - logger.error(`Failed to get content logo for ${type} ID ${id}:`, error); + logger.error(`[TMDBService] Failed to get content logo for ${type} ID ${id}:`, error); return null; } } diff --git a/src/utils/logoUtils.ts b/src/utils/logoUtils.ts new file mode 100644 index 00000000..f82700be --- /dev/null +++ b/src/utils/logoUtils.ts @@ -0,0 +1,83 @@ +import { logger } from './logger'; + +/** + * Checks if a URL is a valid Metahub logo by performing a HEAD request + * @param url The Metahub logo URL to check + * @returns True if the logo is valid, false otherwise + */ +export const isValidMetahubLogo = async (url: string): Promise => { + if (!url || !url.includes('metahub.space')) { + return false; + } + + try { + const response = await fetch(url, { method: 'HEAD' }); + + // Check if request was successful + if (!response.ok) { + logger.warn(`[logoUtils] Logo URL returned status ${response.status}: ${url}`); + return false; + } + + // Check file size to detect "Missing Image" placeholders + const contentLength = response.headers.get('content-length'); + const fileSize = contentLength ? parseInt(contentLength, 10) : 0; + + // If content-length header is missing, we can't check file size, so assume it's valid + if (!contentLength) { + logger.warn(`[logoUtils] No content-length header for URL: ${url}`); + return true; // Give it the benefit of the doubt + } + + // If file size is suspiciously small, it might be a "Missing Image" placeholder + // Check for extremely small files (less than 100 bytes) which are definitely placeholders + if (fileSize < 100) { + logger.warn(`[logoUtils] Logo URL returned extremely small file (${fileSize} bytes), likely a placeholder: ${url}`); + return false; + } + + // For file sizes between 100-500 bytes, they might be small legitimate SVG files + // So we'll allow them through + return true; + } catch (error) { + logger.error(`[logoUtils] Error checking logo URL: ${url}`, error); + // Don't fail hard on network errors, let the image component try to load it + return true; + } +}; + +/** + * Utility to determine if a URL is likely to be a valid logo + * @param url The logo URL to check + * @returns True if the URL pattern suggests a valid logo + */ +export const hasValidLogoFormat = (url: string | null): boolean => { + if (!url) return false; + + // Only reject explicit placeholders, otherwise be permissive + if (url.includes('missing') || url.includes('placeholder.') || url.includes('not-found')) { + return false; + } + + return true; // Allow most URLs to pass through +}; + +/** + * Checks if a URL is from Metahub + * @param url The URL to check + * @returns True if the URL is from Metahub + */ +export const isMetahubUrl = (url: string | null): boolean => { + if (!url) return false; + return url.includes('metahub.space'); +}; + +/** + * Checks if a URL is from TMDB + * @param url The URL to check + * @returns True if the URL is from TMDB + */ +export const isTmdbUrl = (url: string | null): boolean => { + if (!url) return false; + return url.includes('themoviedb.org') || url.includes('tmdb.org') || url.includes('image.tmdb.org'); +}; \ No newline at end of file