Merge branch 'NuvioMedia:main' into main

This commit is contained in:
Tuan Vu 2026-03-15 08:56:01 +07:00 committed by GitHub
commit ca4edfc638
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 91868 additions and 435 deletions

View file

@ -92,8 +92,8 @@ android {
applicationId 'com.nuvio.app' applicationId 'com.nuvio.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 38 versionCode 40
versionName "1.4.2" versionName "1.4.4"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
} }
@ -115,7 +115,7 @@ android {
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4] def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
applicationVariants.all { variant -> applicationVariants.all { variant ->
variant.outputs.each { output -> variant.outputs.each { output ->
def baseVersionCode = 38 // Current versionCode 38 from defaultConfig def baseVersionCode = 40 // Current versionCode 40 from defaultConfig
def abiName = output.getFilter(com.android.build.OutputFile.ABI) def abiName = output.getFilter(com.android.build.OutputFile.ABI)
def versionCode = baseVersionCode * 100 // Base multiplier def versionCode = baseVersionCode * 100 // Base multiplier

View file

@ -3,5 +3,5 @@
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string> <string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string> <string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string> <string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
<string name="expo_runtime_version">1.4.2</string> <string name="expo_runtime_version">1.4.4</string>
</resources> </resources>

View file

@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Nuvio", "name": "Nuvio",
"slug": "nuvio", "slug": "nuvio",
"version": "1.4.2", "version": "1.4.4",
"orientation": "default", "orientation": "default",
"backgroundColor": "#020404", "backgroundColor": "#020404",
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
@ -17,7 +17,7 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
"buildNumber": "38", "buildNumber": "40",
"infoPlist": { "infoPlist": {
"NSAppTransportSecurity": { "NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true "NSAllowsArbitraryLoads": true
@ -60,7 +60,7 @@
"android.permission.WRITE_SETTINGS" "android.permission.WRITE_SETTINGS"
], ],
"package": "com.nuvio.app", "package": "com.nuvio.app",
"versionCode": 38, "versionCode": 40,
"architectures": [ "architectures": [
"arm64-v8a", "arm64-v8a",
"armeabi-v7a", "armeabi-v7a",
@ -113,6 +113,6 @@
"fallbackToCacheTimeout": 30000, "fallbackToCacheTimeout": 30000,
"url": "https://ota.nuvioapp.space/api/manifest" "url": "https://ota.nuvioapp.space/api/manifest"
}, },
"runtimeVersion": "1.4.2" "runtimeVersion": "1.4.4"
} }
} }

View file

@ -19,7 +19,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.4.2</string> <string>1.4.4</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@ -39,7 +39,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>38</string> <string>40</string>
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>
<array> <array>
<string>vlc</string> <string>vlc</string>

View file

@ -111,6 +111,10 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
this.player = player this.player = player
playerView.player = player playerView.player = player
player?.addListener(playerListener) player?.addListener(playerListener)
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
} }
fun setResizeMode(@ResizeMode.Mode mode: Int) { fun setResizeMode(@ResizeMode.Mode mode: Int) {
@ -122,11 +126,10 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
ResizeMode.RESIZE_MODE_CENTER_CROP -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM ResizeMode.RESIZE_MODE_CENTER_CROP -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
} }
if (playerView.width > 0 && playerView.height > 0) { pendingResizeMode = resizeMode
playerView.resizeMode = resizeMode playerView.resizeMode = resizeMode
} else { playerView.requestLayout()
pendingResizeMode = resizeMode requestLayout()
}
// Re-assert subtitle rendering mode for the current style. // Re-assert subtitle rendering mode for the current style.
updateSubtitleRenderingMode() updateSubtitleRenderingMode()
@ -198,7 +201,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
val replacementPlayerView = createPlayerView(viewType).apply { val replacementPlayerView = createPlayerView(viewType).apply {
layoutParams = previousLayoutParams layoutParams = previousLayoutParams
resizeMode = previousResizeMode resizeMode = pendingResizeMode ?: previousResizeMode
useController = previousUseController useController = previousUseController
controllerAutoShow = previousControllerAutoShow controllerAutoShow = previousControllerAutoShow
controllerHideOnTouch = previousControllerHideOnTouch controllerHideOnTouch = previousControllerHideOnTouch
@ -350,7 +353,11 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
fun invalidateAspectRatio() { fun invalidateAspectRatio() {
playerView.post { playerView.post {
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
playerView.requestLayout() playerView.requestLayout()
requestLayout()
} }
} }
@ -364,20 +371,45 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
override fun onTimelineChanged(timeline: Timeline, reason: Int) { override fun onTimelineChanged(timeline: Timeline, reason: Int) {
playerView.post { playerView.post {
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
playerView.requestLayout() playerView.requestLayout()
requestLayout()
}
}
override fun onEvents(player: Player, events: Player.Events) {
if (events.contains(Player.EVENT_VIDEO_SIZE_CHANGED)) {
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
playerView.requestLayout()
requestLayout()
} }
} }
} }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { private val layoutRunnable = Runnable {
super.onMeasure(widthMeasureSpec, heightMeasureSpec) measure(
val width = MeasureSpec.getSize(widthMeasureSpec) MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
val height = MeasureSpec.getSize(heightMeasureSpec) MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
if (width > 0 && height > 0) { )
layout(left, top, right, bottom)
}
override fun requestLayout() {
super.requestLayout()
post(layoutRunnable)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
if (changed) {
pendingResizeMode?.let { resizeMode -> pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode playerView.resizeMode = resizeMode
} }
// Re-apply bottomPaddingFraction once we have a concrete height.
updateSubtitleRenderingMode() updateSubtitleRenderingMode()
applySubtitleStyle(localStyle) applySubtitleStyle(localStyle)
} }
@ -399,7 +431,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
setShowSubtitleButton(showSubtitleButton) setShowSubtitleButton(showSubtitleButton)
setUseArtwork(false) setUseArtwork(false)
setDefaultArtwork(null) setDefaultArtwork(null)
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT resizeMode = pendingResizeMode ?: AspectRatioFrameLayout.RESIZE_MODE_FIT
if (viewType == ViewType.VIEW_TYPE_SURFACE_SECURE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { if (viewType == ViewType.VIEW_TYPE_SURFACE_SECURE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
(videoSurfaceView as? SurfaceView)?.setSecure(true) (videoSurfaceView as? SurfaceView)?.setSecure(true)

View file

@ -732,7 +732,7 @@ public class ReactExoplayerView extends FrameLayout implements
DefaultRenderersFactory renderersFactory = DefaultRenderersFactory renderersFactory =
new DefaultRenderersFactory(getContext()) new DefaultRenderersFactory(getContext())
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF) .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
.setEnableDecoderFallback(true) .setEnableDecoderFallback(true)
.forceEnableMediaCodecAsynchronousQueueing(); .forceEnableMediaCodecAsynchronousQueueing();

File diff suppressed because one or more lines are too long

View file

@ -193,38 +193,36 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
case 'watched': { case 'watched': {
const targetWatched = !isWatched; const targetWatched = !isWatched;
setIsWatched(targetWatched); setIsWatched(targetWatched);
try {
await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
} catch { }
showInfo(targetWatched ? t('library.marked_watched') : t('library.marked_unwatched'), targetWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched'));
setTimeout(() => {
DeviceEventEmitter.emit('watchedStatusChanged');
}, 100);
// Best-effort sync: record local progress and push to Trakt if available // Use the centralized watchedService to handle all the sync logic (Supabase, Trakt, Simkl, MAL)
if (targetWatched) { import('../../services/watchedService').then(({ watchedService }) => {
try { if (targetWatched) {
await storageService.setWatchProgress( if (item.type === 'movie') {
item.id, // Pass the title so it correctly populates the database instead of defaulting to IMDb ID
item.type, watchedService.markMovieAsWatched(item.id, new Date(), undefined, undefined, item.name);
{ currentTime: 1, duration: 1, lastUpdated: Date.now() }, } else {
undefined, // For series from the homescreen drop-down, we mark S1E1 as watched as a baseline
{ forceNotify: true, forceWrite: true } watchedService.markEpisodeAsWatched(item.id, item.id, 1, 1, new Date(), undefined, item.name);
); }
} catch { } } else {
if (item.type === 'movie') {
if (item.type === 'movie') { watchedService.unmarkMovieAsWatched(item.id);
try { } else {
const trakt = TraktService.getInstance(); // Unmarking a series from the top level is tricky as we don't know the exact episodes.
if (await trakt.isAuthenticated()) { // For safety and consistency with old behavior, we just clear the legacy flag.
await trakt.addToWatchedMovies(item.id); mmkvStorage.removeItem(`watched:${item.type}:${item.id}`);
try { }
await storageService.updateTraktSyncStatus(item.id, item.type, true, 100);
} catch { }
}
} catch { }
} }
}
showInfo(
targetWatched ? t('library.marked_watched') : t('library.marked_unwatched'),
targetWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched')
);
setTimeout(() => {
DeviceEventEmitter.emit('watchedStatusChanged');
}, 100);
});
setMenuVisible(false); setMenuVisible(false);
break; break;
} }

View file

@ -14,6 +14,24 @@ interface AddonSectionProps {
currentTheme: any; currentTheme: any;
} }
const TYPE_LABELS: Record<string, string> = {
'movie': 'search.movies',
'series': 'search.tv_shows',
'anime.movie': 'search.anime_movies',
'anime.series': 'search.anime_series',
};
const subtitleStyle = (currentTheme: any) => ({
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16,
});
const containerStyle = {
marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24,
};
export const AddonSection = React.memo(({ export const AddonSection = React.memo(({
addonGroup, addonGroup,
addonIndex, addonIndex,
@ -23,25 +41,34 @@ export const AddonSection = React.memo(({
}: AddonSectionProps) => { }: AddonSectionProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const movieResults = useMemo(() => // Group results by their exact type, preserving order of first appearance
addonGroup.results.filter(item => item.type === 'movie'), const groupedByType = useMemo(() => {
[addonGroup.results] const order: string[] = [];
); const groups: Record<string, StreamingContent[]> = {};
const seriesResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'series'), for (const item of addonGroup.results) {
[addonGroup.results] if (!groups[item.type]) {
); order.push(item.type);
const otherResults = useMemo(() => groups[item.type] = [];
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'), }
[addonGroup.results] groups[item.type].push(item);
); }
return order.map(type => ({ type, items: groups[type] }));
}, [addonGroup.results]);
const getLabelForType = (type: string): string => {
if (TYPE_LABELS[type]) return t(TYPE_LABELS[type]);
// Fallback: capitalise and replace dots/underscores for unknown types
return type.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
};
return ( return (
<View> <View>
{/* Addon Header */} {/* Addon Header */}
<View style={styles.addonHeaderContainer}> <View style={styles.addonHeaderContainer}>
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}> <Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
{addonGroup.addonName} {(addonGroup as any).sectionName || addonGroup.addonName}
</Text> </Text>
<View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}> <View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}> <Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}>
@ -50,22 +77,13 @@ export const AddonSection = React.memo(({
</View> </View>
</View> </View>
{/* Movies */} {groupedByType.map(({ type, items }) => (
{movieResults.length > 0 && ( <View key={type} style={[styles.carouselContainer, containerStyle]}>
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}> <Text style={[styles.carouselSubtitle, subtitleStyle(currentTheme)]}>
<Text style={[ {getLabelForType(type)} ({items.length})
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
{t('search.movies')} ({movieResults.length})
</Text> </Text>
<FlatList <FlatList
data={movieResults} data={items}
renderItem={({ item, index }) => ( renderItem={({ item, index }) => (
<SearchResultItem <SearchResultItem
item={item} item={item}
@ -74,81 +92,16 @@ export const AddonSection = React.memo(({
onLongPress={onItemLongPress} onLongPress={onItemLongPress}
/> />
)} )}
keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`} keyExtractor={item => `${addonGroup.addonId}-${type}-${item.id}`}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent} contentContainerStyle={styles.horizontalListContent}
/> />
</View> </View>
)} ))}
{/* TV Shows */}
{seriesResults.length > 0 && (
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
{t('search.tv_shows')} ({seriesResults.length})
</Text>
<FlatList
data={seriesResults}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
onPress={onItemPress}
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
{/* Other types */}
{otherResults.length > 0 && (
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
</Text>
<FlatList
data={otherResults}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
onPress={onItemPress}
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
</View> </View>
); );
}, (prev, next) => { }, (prev, next) => {
// Only re-render if this section's reference changed
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex; return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
}); });

View file

@ -11,12 +11,13 @@ interface DiscoverBottomSheetsProps {
typeSheetRef: RefObject<BottomSheetModal>; typeSheetRef: RefObject<BottomSheetModal>;
catalogSheetRef: RefObject<BottomSheetModal>; catalogSheetRef: RefObject<BottomSheetModal>;
genreSheetRef: RefObject<BottomSheetModal>; genreSheetRef: RefObject<BottomSheetModal>;
selectedDiscoverType: 'movie' | 'series'; selectedDiscoverType: string;
selectedCatalog: DiscoverCatalog | null; selectedCatalog: DiscoverCatalog | null;
selectedDiscoverGenre: string | null; selectedDiscoverGenre: string | null;
filteredCatalogs: DiscoverCatalog[]; filteredCatalogs: DiscoverCatalog[];
availableGenres: string[]; availableGenres: string[];
onTypeSelect: (type: 'movie' | 'series') => void; availableTypes: string[];
onTypeSelect: (type: string) => void;
onCatalogSelect: (catalog: DiscoverCatalog) => void; onCatalogSelect: (catalog: DiscoverCatalog) => void;
onGenreSelect: (genre: string | null) => void; onGenreSelect: (genre: string | null) => void;
currentTheme: any; currentTheme: any;
@ -31,6 +32,7 @@ export const DiscoverBottomSheets = ({
selectedDiscoverGenre, selectedDiscoverGenre,
filteredCatalogs, filteredCatalogs,
availableGenres, availableGenres,
availableTypes,
onTypeSelect, onTypeSelect,
onCatalogSelect, onCatalogSelect,
onGenreSelect, onGenreSelect,
@ -38,7 +40,20 @@ export const DiscoverBottomSheets = ({
}: DiscoverBottomSheetsProps) => { }: DiscoverBottomSheetsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const typeSnapPoints = useMemo(() => ['25%'], []); const TYPE_LABELS: Record<string, string> = {
'movie': t('search.movies'),
'series': t('search.tv_shows'),
'anime.movie': t('search.anime_movies'),
'anime.series': t('search.anime_series'),
};
const getLabelForType = (type: string) =>
TYPE_LABELS[type] ?? type.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const typeSnapPoints = useMemo(() => {
const itemCount = availableTypes.length;
const snapPct = Math.min(20 + itemCount * 10, 60);
return [`${snapPct}%`];
}, [availableTypes]);
const catalogSnapPoints = useMemo(() => ['50%'], []); const catalogSnapPoints = useMemo(() => ['50%'], []);
const genreSnapPoints = useMemo(() => ['50%'], []); const genreSnapPoints = useMemo(() => ['50%'], []);
const [activeBottomSheetRef, setActiveBottomSheetRef] = useState(null); const [activeBottomSheetRef, setActiveBottomSheetRef] = useState(null);
@ -225,47 +240,25 @@ export const DiscoverBottomSheets = ({
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }} style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent} contentContainerStyle={styles.bottomSheetContent}
> >
{/* Movies option */} {availableTypes.map((type) => (
<TouchableOpacity <TouchableOpacity
style={[ key={type}
styles.bottomSheetItem, style={[
selectedDiscoverType === 'movie' && styles.bottomSheetItemSelected styles.bottomSheetItem,
]} selectedDiscoverType === type && styles.bottomSheetItemSelected
onPress={() => onTypeSelect('movie')} ]}
> onPress={() => onTypeSelect(type)}
<View style={styles.bottomSheetItemContent}> >
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}> <View style={styles.bottomSheetItemContent}>
{t('search.movies')} <Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
</Text> {getLabelForType(type)}
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}> </Text>
{t('search.browse_movies')} </View>
</Text> {selectedDiscoverType === type && (
</View> <MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
{selectedDiscoverType === 'movie' && ( )}
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} /> </TouchableOpacity>
)} ))}
</TouchableOpacity>
{/* TV Shows option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
selectedDiscoverType === 'series' && styles.bottomSheetItemSelected
]}
onPress={() => onTypeSelect('series')}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{t('search.tv_shows')}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{t('search.browse_tv')}
</Text>
</View>
{selectedDiscoverType === 'series' && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </BottomSheetModal>
</> </>

View file

@ -22,7 +22,7 @@ interface DiscoverSectionProps {
pendingDiscoverResults: StreamingContent[]; pendingDiscoverResults: StreamingContent[];
loadingMore: boolean; loadingMore: boolean;
selectedCatalog: DiscoverCatalog | null; selectedCatalog: DiscoverCatalog | null;
selectedDiscoverType: 'movie' | 'series'; selectedDiscoverType: string;
selectedDiscoverGenre: string | null; selectedDiscoverGenre: string | null;
availableGenres: string[]; availableGenres: string[];
typeSheetRef: React.RefObject<BottomSheetModal>; typeSheetRef: React.RefObject<BottomSheetModal>;
@ -78,7 +78,11 @@ export const DiscoverSection = ({
onPress={() => typeSheetRef.current?.present()} onPress={() => typeSheetRef.current?.present()}
> >
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}> <Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{selectedDiscoverType === 'movie' ? t('search.movies') : t('search.tv_shows')} {selectedDiscoverType === 'movie' ? t('search.movies')
: selectedDiscoverType === 'series' ? t('search.tv_shows')
: selectedDiscoverType === 'anime.movie' ? t('search.anime_movies')
: selectedDiscoverType === 'anime.series' ? t('search.anime_series')
: selectedDiscoverType.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
</Text> </Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} /> <MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity> </TouchableOpacity>
@ -112,8 +116,13 @@ export const DiscoverSection = ({
{selectedCatalog && ( {selectedCatalog && (
<View style={styles.discoverFilterSummary}> <View style={styles.discoverFilterSummary}>
<Text style={[styles.discoverFilterSummaryText, { color: currentTheme.colors.lightGray }]}> <Text style={[styles.discoverFilterSummaryText, { color: currentTheme.colors.lightGray }]}>
{selectedCatalog.addonName} {selectedCatalog.type === 'movie' ? t('search.movies') : t('search.tv_shows')} {selectedCatalog.addonName} {
{selectedDiscoverGenre ? `${selectedDiscoverGenre}` : ''} selectedCatalog.type === 'movie' ? t('search.movies')
: selectedCatalog.type === 'series' ? t('search.tv_shows')
: selectedCatalog.type === 'anime.movie' ? t('search.anime_movies')
: selectedCatalog.type === 'anime.series' ? t('search.anime_series')
: selectedCatalog.type.replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}{selectedDiscoverGenre ? `${selectedDiscoverGenre}` : ''}
</Text> </Text>
</View> </View>
)} )}

View file

@ -114,6 +114,13 @@ interface UseMetadataReturn {
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => { export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
const { settings, isLoaded: settingsLoaded } = useSettings(); const { settings, isLoaded: settingsLoaded } = useSettings();
// Normalize anime subtypes to their base types for all internal logic.
// anime.series behaves like series; anime.movie behaves like movie.
const normalizedType = type === 'anime.series' ? 'series'
: type === 'anime.movie' ? 'movie'
: type;
const [metadata, setMetadata] = useState<StreamingContent | null>(null); const [metadata, setMetadata] = useState<StreamingContent | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -427,7 +434,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
return; return;
} }
// Check cache first // Check cache first
const cachedCast = cacheService.getCast(id, type); const cachedCast = cacheService.getCast(id, normalizedType);
if (cachedCast) { if (cachedCast) {
if (__DEV__) logger.log('[loadCast] Using cached cast data'); if (__DEV__) logger.log('[loadCast] Using cached cast data');
setCast(cachedCast); setCast(cachedCast);
@ -439,7 +446,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (id.startsWith('tmdb:')) { if (id.startsWith('tmdb:')) {
const tmdbId = id.split(':')[1]; const tmdbId = id.split(':')[1];
if (__DEV__) logger.log('[loadCast] Using TMDB ID directly:', tmdbId); if (__DEV__) logger.log('[loadCast] Using TMDB ID directly:', tmdbId);
const castData = await tmdbService.getCredits(parseInt(tmdbId), type); const castData = await tmdbService.getCredits(parseInt(tmdbId), normalizedType);
if (castData && castData.cast) { if (castData && castData.cast) {
const formattedCast = castData.cast.map((actor: any) => ({ const formattedCast = castData.cast.map((actor: any) => ({
id: actor.id, id: actor.id,
@ -464,7 +471,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (tmdbId) { if (tmdbId) {
if (__DEV__) logger.log('[loadCast] Fetching cast using TMDB ID:', tmdbId); if (__DEV__) logger.log('[loadCast] Fetching cast using TMDB ID:', tmdbId);
const castData = await tmdbService.getCredits(tmdbId, type); const castData = await tmdbService.getCredits(tmdbId, normalizedType);
if (castData && castData.cast) { if (castData && castData.cast) {
const formattedCast = castData.cast.map((actor: any) => ({ const formattedCast = castData.cast.map((actor: any) => ({
id: actor.id, id: actor.id,
@ -511,7 +518,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setLoadAttempts(prev => prev + 1); setLoadAttempts(prev => prev + 1);
// Check metadata screen cache // Check metadata screen cache
const cachedScreen = cacheService.getMetadataScreen(id, type); const cachedScreen = cacheService.getMetadataScreen(id, normalizedType);
if (cachedScreen) { if (cachedScreen) {
console.log('🔍 [useMetadata] Using cached metadata:', { console.log('🔍 [useMetadata] Using cached metadata:', {
id, id,
@ -523,7 +530,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}); });
setMetadata(cachedScreen.metadata); setMetadata(cachedScreen.metadata);
setCast(cachedScreen.cast); setCast(cachedScreen.cast);
if (type === 'series' && cachedScreen.episodes) { if (normalizedType === 'series' && cachedScreen.episodes) {
setGroupedEpisodes(cachedScreen.episodes.groupedEpisodes); setGroupedEpisodes(cachedScreen.episodes.groupedEpisodes);
setEpisodes(cachedScreen.episodes.currentEpisodes); setEpisodes(cachedScreen.episodes.currentEpisodes);
setSelectedSeason(cachedScreen.episodes.selectedSeason); setSelectedSeason(cachedScreen.episodes.selectedSeason);
@ -567,7 +574,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
} else { } else {
const tmdbId = id.split(':')[1]; const tmdbId = id.split(':')[1];
// For TMDB IDs, we need to handle metadata differently // For TMDB IDs, we need to handle metadata differently
if (type === 'movie') { if (normalizedType === 'movie') {
if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId); if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId);
const movieDetails = await tmdbService.getMovieDetails( const movieDetails = await tmdbService.getMovieDetails(
tmdbId, tmdbId,
@ -639,7 +646,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
} }
setMetadata(formattedMovie); setMetadata(formattedMovie);
cacheService.setMetadata(id, type, formattedMovie); cacheService.setMetadata(id, normalizedType, formattedMovie);
(async () => { (async () => {
const items = await catalogService.getLibraryItems(); const items = await catalogService.getLibraryItems();
const isInLib = items.some(item => item.id === id); const isInLib = items.some(item => item.id === id);
@ -649,7 +656,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
return; return;
} }
} }
} else if (type === 'series') { } else if (normalizedType === 'series') {
// Handle TV shows with TMDB IDs // Handle TV shows with TMDB IDs
if (__DEV__) logger.log('Fetching TV show details from TMDB for:', tmdbId); if (__DEV__) logger.log('Fetching TV show details from TMDB for:', tmdbId);
try { try {
@ -719,7 +726,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
} }
setMetadata(formattedShow); setMetadata(formattedShow);
cacheService.setMetadata(id, type, formattedShow); cacheService.setMetadata(id, normalizedType, formattedShow);
// Load series data (episodes) // Load series data (episodes)
setTmdbId(parseInt(tmdbId)); setTmdbId(parseInt(tmdbId));
@ -779,7 +786,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
for (const addon of externalMetaAddons) { for (const addon of externalMetaAddons) {
try { try {
const result = await withTimeout( const result = await withTimeout(
stremioService.getMetaDetails(type, actualId, addon.id), stremioService.getMetaDetails(normalizedType, actualId, addon.id),
API_TIMEOUT API_TIMEOUT
); );
@ -799,7 +806,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// If no external addon worked, fall back to catalog addon // If no external addon worked, fall back to catalog addon
console.log('🔍 [useMetadata] No external meta addon worked, falling back to catalog addon'); console.log('🔍 [useMetadata] No external meta addon worked, falling back to catalog addon');
const result = await withTimeout( const result = await withTimeout(
catalogService.getEnhancedContentDetails(type, actualId, addonId), catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
API_TIMEOUT API_TIMEOUT
); );
if (actualId.startsWith('tt')) { if (actualId.startsWith('tt')) {
@ -831,7 +838,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
console.log('⚡ [useMetadata] Calling catalogService.getEnhancedContentDetails...'); console.log('⚡ [useMetadata] Calling catalogService.getEnhancedContentDetails...');
console.log('🔍 [useMetadata] Calling catalogService.getEnhancedContentDetails:', { type, actualId, addonId }); console.log('🔍 [useMetadata] Calling catalogService.getEnhancedContentDetails:', { type, actualId, addonId });
const result = await withTimeout( const result = await withTimeout(
catalogService.getEnhancedContentDetails(type, actualId, addonId), catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
API_TIMEOUT API_TIMEOUT
); );
console.log('✅ [useMetadata] catalogService returned:', result ? 'DATA' : 'NULL'); console.log('✅ [useMetadata] catalogService returned:', result ? 'DATA' : 'NULL');
@ -871,13 +878,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
console.log('🔍 [useMetadata] Original TMDB ID failed, trying ID conversion fallback'); console.log('🔍 [useMetadata] Original TMDB ID failed, trying ID conversion fallback');
const tmdbRaw = id.split(':')[1]; const tmdbRaw = id.split(':')[1];
try { try {
const stremioId = await catalogService.getStremioId(type === 'series' ? 'tv' : 'movie', tmdbRaw); const stremioId = await catalogService.getStremioId(normalizedType === 'series' ? 'tv' : 'movie', tmdbRaw);
if (stremioId && stremioId !== id) { if (stremioId && stremioId !== id) {
console.log('🔍 [useMetadata] Trying converted ID:', { originalId: id, convertedId: stremioId }); console.log('🔍 [useMetadata] Trying converted ID:', { originalId: id, convertedId: stremioId });
const [content, castData] = await Promise.allSettled([ const [content, castData] = await Promise.allSettled([
withRetry(async () => { withRetry(async () => {
const result = await withTimeout( const result = await withTimeout(
catalogService.getEnhancedContentDetails(type, stremioId, addonId), catalogService.getEnhancedContentDetails(normalizedType, stremioId, addonId),
API_TIMEOUT API_TIMEOUT
); );
if (stremioId.startsWith('tt')) { if (stremioId.startsWith('tt')) {
@ -934,7 +941,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (finalTmdbId) { if (finalTmdbId) {
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
if (type === 'movie') { if (normalizedType === 'movie') {
const localized = await tmdbSvc.getMovieDetails(String(finalTmdbId), lang); const localized = await tmdbSvc.getMovieDetails(String(finalTmdbId), lang);
if (localized) { if (localized) {
const movieDetailsObj = { const movieDetailsObj = {
@ -1011,7 +1018,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) { if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) {
const tmdbService = TMDBService.getInstance(); const tmdbService = TMDBService.getInstance();
const preferredLanguage = settings.tmdbLanguagePreference || 'en'; const preferredLanguage = settings.tmdbLanguagePreference || 'en';
const contentType = type === 'series' ? 'tv' : 'movie'; const contentType = normalizedType === 'series' ? 'tv' : 'movie';
// Get TMDB ID // Get TMDB ID
let tmdbIdForLogo = null; let tmdbIdForLogo = null;
@ -1080,7 +1087,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
} }
return updated; return updated;
}); });
cacheService.setMetadata(id, type, finalMetadata); cacheService.setMetadata(id, normalizedType, finalMetadata);
(async () => { (async () => {
const items = await catalogService.getLibraryItems(); const items = await catalogService.getLibraryItems();
const isInLib = items.some(item => item.id === id); const isInLib = items.some(item => item.id === id);
@ -1597,10 +1604,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format) // Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format)
try { try {
let externalIds = null; let externalIds = null;
if (type === 'movie') { if (normalizedType === 'movie') {
const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT); const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT);
externalIds = movieDetails?.external_ids; externalIds = movieDetails?.external_ids;
} else if (type === 'series') { } else if (normalizedType === 'series') {
externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT); externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
} }
@ -1829,7 +1836,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
return false; return false;
}); });
const requestedEpisodeType = type; const requestedEpisodeType = normalizedType;
let streamAddons = pickStreamCapableAddons(requestedEpisodeType); let streamAddons = pickStreamCapableAddons(requestedEpisodeType);
if (streamAddons.length === 0) { if (streamAddons.length === 0) {
@ -1917,13 +1924,16 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const cleanEpisodeId = episodeId.replace(/^series:/, ''); const cleanEpisodeId = episodeId.replace(/^series:/, '');
const parts = cleanEpisodeId.split(':'); const parts = cleanEpisodeId.split(':');
// Check the episode ID's own namespace, not the show-level id.
// e.g. show id may be "tt12343534" but episodeId may be "kitsu:48363:8"
const episodeIsImdb = parts[0].startsWith('tt');
if (isImdb && parts.length === 3) { if (episodeIsImdb && parts.length === 3) {
// Format: ttXXX:season:episode // Format: ttXXX:season:episode
showIdStr = parts[0]; showIdStr = parts[0];
seasonNum = parts[1]; seasonNum = parts[1];
episodeNum = parts[2]; episodeNum = parts[2];
} else if (!isImdb && parts.length === 3) { } else if (!episodeIsImdb && parts.length === 3) {
// Format: prefix:id:episode (no season for MAL/Kitsu/etc) // Format: prefix:id:episode (no season for MAL/Kitsu/etc)
showIdStr = `${parts[0]}:${parts[1]}`; showIdStr = `${parts[0]}:${parts[1]}`;
episodeNum = parts[2]; episodeNum = parts[2];
@ -2009,16 +2019,18 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
} }
if (__DEV__) console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId); if (__DEV__) console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
// Ensure consistent format // Ensure consistent format or fallback to episodeId if parsing failed.
// Ensure consistent format or fallback to episodeId if parsing failed // If the episode's namespace differs from the show's tt id (e.g. kitsu:48363:8
// This handles cases where 'tt' is used for a unique episode ID directly // on a tt-identified show), use showIdStr so we request via the correct namespace.
if (!seasonNum && !episodeNum) { if (!seasonNum && !episodeNum) {
stremioEpisodeId = episodeId; stremioEpisodeId = episodeId;
} else if (!seasonNum) { } else if (!seasonNum) {
// No season (e.g., mal:57658:1) - use id:episode format // No season (e.g., kitsu:48363:8, mal:57658:1)
stremioEpisodeId = `${id}:${episodeNum}`; const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
stremioEpisodeId = `${baseId}:${episodeNum}`;
} else { } else {
stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`; const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
stremioEpisodeId = `${baseId}:${seasonNum}:${episodeNum}`;
} }
if (__DEV__) console.log('🔧 [loadEpisodeStreams] Normalized episode ID for addons:', stremioEpisodeId); if (__DEV__) console.log('🔧 [loadEpisodeStreams] Normalized episode ID for addons:', stremioEpisodeId);
} else { } else {
@ -2029,12 +2041,17 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Remove 'series:' prefix if present to be safe, though parsing logic above usually handles it // Remove 'series:' prefix if present to be safe, though parsing logic above usually handles it
stremioEpisodeId = episodeId.replace(/^series:/, ''); stremioEpisodeId = episodeId.replace(/^series:/, '');
} else if (!seasonNum) { } else if (!seasonNum) {
// No season (e.g., mal:57658:1) - use id:episode format // No season (e.g., kitsu:12345:1, mal:57658:1) - use showIdStr:episode format.
stremioEpisodeId = `${id}:${episodeNum}`; // Use showIdStr (parsed from episodeId) rather than outer `id` so that when the
// show has multiple IDs (e.g. tvdb+kitsu), we preserve the namespace that the
// episode actually belongs to (e.g. kitsu:animeId:epNum, not tvdb:showId:epNum).
const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
stremioEpisodeId = `${baseId}:${episodeNum}`;
} else { } else {
stremioEpisodeId = `${id}:${seasonNum}:${episodeNum}`; const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
stremioEpisodeId = `${baseId}:${seasonNum}:${episodeNum}`;
} }
if (__DEV__) console.log(' [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId); if (__DEV__) console.log(' [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId, '| stremioEpisodeId:', stremioEpisodeId);
} }
// Extract episode info from the episodeId for logging // Extract episode info from the episodeId for logging
@ -2111,7 +2128,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (!metadata) return; if (!metadata) return;
if (inLibrary) { if (inLibrary) {
catalogService.removeFromLibrary(type, id); catalogService.removeFromLibrary(normalizedType, id);
} else { } else {
catalogService.addToLibrary(metadata); catalogService.addToLibrary(metadata);
} }
@ -2190,12 +2207,12 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
try { try {
const tmdbService = TMDBService.getInstance(); const tmdbService = TMDBService.getInstance();
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId), lang); const results = await tmdbService.getRecommendations(normalizedType === 'movie' ? 'movie' : 'tv', String(tmdbId), lang);
// Convert TMDB results to StreamingContent format (simplified) // Convert TMDB results to StreamingContent format (simplified)
const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({ const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({
id: `tmdb:${item.id}`, id: `tmdb:${item.id}`,
type: type === 'movie' ? 'movie' : 'series', type: normalizedType === 'movie' ? 'movie' : 'series',
name: item.title || item.name || 'Untitled', name: item.title || item.name || 'Untitled',
poster: tmdbService.getImageUrl(item.poster_path) || 'https://via.placeholder.com/300x450', // Provide fallback poster: tmdbService.getImageUrl(item.poster_path) || 'https://via.placeholder.com/300x450', // Provide fallback
year: (item.release_date || item.first_air_date)?.substring(0, 4) || 'N/A', // Ensure string and provide fallback year: (item.release_date || item.first_air_date)?.substring(0, 4) || 'N/A', // Ensure string and provide fallback
@ -2226,7 +2243,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setTmdbId(fetchedTmdbId); setTmdbId(fetchedTmdbId);
// Fetch certification only if granular setting is enabled // Fetch certification only if granular setting is enabled
if (settings.tmdbEnrichCertification) { if (settings.tmdbEnrichCertification) {
const certification = await tmdbService.getCertification(type, fetchedTmdbId); const certification = await tmdbService.getCertification(normalizedType, fetchedTmdbId);
if (certification) { if (certification) {
if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification }); if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification });
setMetadata(prev => prev ? { setMetadata(prev => prev ? {
@ -2299,7 +2316,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
return; return;
} }
const tmdbSvc = TMDBService.getInstance(); const tmdbSvc = TMDBService.getInstance();
const cert = await tmdbSvc.getCertification(type, tmdbId); const cert = await tmdbSvc.getCertification(normalizedType, tmdbId);
if (cert) { if (cert) {
if (__DEV__) console.log('[useMetadata] fetched certification (attach path)', { type, tmdbId, cert }); if (__DEV__) console.log('[useMetadata] fetched certification (attach path)', { type, tmdbId, cert });
setMetadata(prev => prev ? { ...prev, tmdbId, certification: cert } : prev); setMetadata(prev => prev ? { ...prev, tmdbId, certification: cert } : prev);
@ -2326,7 +2343,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
return; return;
} }
const contentKey = `${type}-${tmdbId}`; const contentKey = `${normalizedType}-${tmdbId}`;
if (productionInfoFetchedRef.current === contentKey) { if (productionInfoFetchedRef.current === contentKey) {
return; return;
} }
@ -2334,7 +2351,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Only skip if networks are set AND collection is already set (for movies) // Only skip if networks are set AND collection is already set (for movies)
const hasNetworks = !!(metadata as any).networks; const hasNetworks = !!(metadata as any).networks;
const hasCollection = !!(metadata as any).collection; const hasCollection = !!(metadata as any).collection;
if (hasNetworks && (type !== 'movie' || hasCollection)) { if (hasNetworks && (normalizedType !== 'movie' || hasCollection)) {
return; return;
} }
@ -2357,7 +2374,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
collectionsEnabled: settings.tmdbEnrichCollections collectionsEnabled: settings.tmdbEnrichCollections
}); });
if (type === 'series') { if (normalizedType === 'series') {
// Fetch networks and additional details for TV shows // Fetch networks and additional details for TV shows
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
const showDetails = await tmdbService.getTVShowDetails(tmdbId, lang); const showDetails = await tmdbService.getTVShowDetails(tmdbId, lang);
@ -2406,7 +2423,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
})); }));
} }
} }
} else if (type === 'movie') { } else if (normalizedType === 'movie') {
// Fetch production companies and additional details for movies // Fetch production companies and additional details for movies
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
const movieDetails = await tmdbService.getMovieDetails(String(tmdbId), lang); const movieDetails = await tmdbService.getMovieDetails(String(tmdbId), lang);

View file

@ -244,7 +244,34 @@ const HomeScreen = () => {
for (const addon of addons) { for (const addon of addons) {
if (addon.catalogs) { if (addon.catalogs) {
for (const catalog of addon.catalogs) { for (const catalog of addon.catalogs) {
// Check if this catalog is enabled (default to true if no setting exists) // ── Manifest-level hard gates (cannot be overridden by user settings) ──
// 1. Never show search catalogs (search.movie, search.series, etc.)
if (
(catalog.id && catalog.id.startsWith('search.')) ||
(catalog.type && catalog.type.startsWith('search'))
) {
continue;
}
// 2. Never show catalogs that have any required extra
// (e.g. required genre, calendarVideosIds — these need params to load)
const requiredExtras = (catalog.extra || [])
.filter((e: any) => e.isRequired)
.map((e: any) => e.name);
if (requiredExtras.length > 0) {
continue;
}
// 3. Respect showInHome flag — if the addon uses it on any catalog,
// only catalogs with showInHome:true are eligible for home.
const addonUsesShowInHome = addon.catalogs.some((c: any) => c.showInHome === true);
if (addonUsesShowInHome && !(catalog as any).showInHome) {
continue;
}
// ── User toggle (mmkv) — applied on top of manifest gates ──
// Default is true unless the manifest gates above have already filtered it out.
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`; const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
const isEnabled = catalogSettings[settingKey] ?? true; const isEnabled = catalogSettings[settingKey] ?? true;

View file

@ -84,7 +84,7 @@ const SearchScreen = () => {
// Discover section state // Discover section state
const [discoverCatalogs, setDiscoverCatalogs] = useState<DiscoverCatalog[]>([]); const [discoverCatalogs, setDiscoverCatalogs] = useState<DiscoverCatalog[]>([]);
const [selectedCatalog, setSelectedCatalog] = useState<DiscoverCatalog | null>(null); const [selectedCatalog, setSelectedCatalog] = useState<DiscoverCatalog | null>(null);
const [selectedDiscoverType, setSelectedDiscoverType] = useState<'movie' | 'series'>('movie'); const [selectedDiscoverType, setSelectedDiscoverType] = useState<string>('movie');
const [selectedDiscoverGenre, setSelectedDiscoverGenre] = useState<string | null>(null); const [selectedDiscoverGenre, setSelectedDiscoverGenre] = useState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
@ -127,7 +127,7 @@ const SearchScreen = () => {
try { try {
// Load saved type // Load saved type
const savedType = await mmkvStorage.getItem(DISCOVER_TYPE_KEY); const savedType = await mmkvStorage.getItem(DISCOVER_TYPE_KEY);
if (savedType && (savedType === 'movie' || savedType === 'series')) { if (savedType) {
setSelectedDiscoverType(savedType); setSelectedDiscoverType(savedType);
} }
@ -141,7 +141,7 @@ const SearchScreen = () => {
}, []); }, []);
// Save discover settings when they change // Save discover settings when they change
const saveDiscoverSettings = useCallback(async (type: 'movie' | 'series', catalog: DiscoverCatalog | null, genre: string | null) => { const saveDiscoverSettings = useCallback(async (type: string, catalog: DiscoverCatalog | null, genre: string | null) => {
try { try {
// Save type // Save type
await mmkvStorage.setItem(DISCOVER_TYPE_KEY, type); await mmkvStorage.setItem(DISCOVER_TYPE_KEY, type);
@ -267,10 +267,8 @@ const SearchScreen = () => {
if (isMounted.current) { if (isMounted.current) {
const allCatalogs: DiscoverCatalog[] = []; const allCatalogs: DiscoverCatalog[] = [];
for (const [type, catalogs] of Object.entries(filters.catalogsByType)) { for (const [type, catalogs] of Object.entries(filters.catalogsByType)) {
if (type === 'movie' || type === 'series') { for (const catalog of catalogs) {
for (const catalog of catalogs) { allCatalogs.push({ ...catalog, type });
allCatalogs.push({ ...catalog, type });
}
} }
} }
setDiscoverCatalogs(allCatalogs); setDiscoverCatalogs(allCatalogs);
@ -517,7 +515,14 @@ const SearchScreen = () => {
setResults(prev => { setResults(prev => {
if (!isMounted.current) return prev; if (!isMounted.current) return prev;
const getRank = (id: string) => addonOrderRankRef.current[id] ?? Number.MAX_SAFE_INTEGER; // Use catalogIndex from the section for deterministic ordering.
// Falls back to addonOrderRankRef for legacy single-catalog sections.
const getRank = (section: AddonSearchResults) => {
if (section.catalogIndex !== undefined) return section.catalogIndex;
if (addonOrderRankRef.current[section.addonId] !== undefined) return addonOrderRankRef.current[section.addonId] * 1000;
const baseAddonId = section.addonId.includes('||') ? section.addonId.split('||')[0] : section.addonId;
return (addonOrderRankRef.current[baseAddonId] ?? Number.MAX_SAFE_INTEGER - 1) * 1000 + 500;
};
const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId); const existingIndex = prev.byAddon.findIndex(s => s.addonId === section.addonId);
if (existingIndex >= 0) { if (existingIndex >= 0) {
@ -526,10 +531,10 @@ const SearchScreen = () => {
return { byAddon: copy, allResults: prev.allResults }; return { byAddon: copy, allResults: prev.allResults };
} }
const insertRank = getRank(section.addonId); const insertRank = getRank(section);
let insertAt = prev.byAddon.length; let insertAt = prev.byAddon.length;
for (let i = 0; i < prev.byAddon.length; i++) { for (let i = 0; i < prev.byAddon.length; i++) {
if (getRank(prev.byAddon[i].addonId) > insertRank) { if (getRank(prev.byAddon[i]) > insertRank) {
insertAt = i; insertAt = i;
break; break;
} }
@ -636,9 +641,10 @@ const SearchScreen = () => {
}; };
const availableGenres = useMemo(() => selectedCatalog?.genres || [], [selectedCatalog]); const availableGenres = useMemo(() => selectedCatalog?.genres || [], [selectedCatalog]);
const availableTypes = useMemo(() => [...new Set(discoverCatalogs.map(c => c.type))], [discoverCatalogs]);
const filteredCatalogs = useMemo(() => discoverCatalogs.filter(c => c.type === selectedDiscoverType), [discoverCatalogs, selectedDiscoverType]); const filteredCatalogs = useMemo(() => discoverCatalogs.filter(c => c.type === selectedDiscoverType), [discoverCatalogs, selectedDiscoverType]);
const handleTypeSelect = (type: 'movie' | 'series') => { const handleTypeSelect = (type: string) => {
setSelectedDiscoverType(type); setSelectedDiscoverType(type);
// Save type setting // Save type setting
@ -893,6 +899,7 @@ const SearchScreen = () => {
selectedDiscoverGenre={selectedDiscoverGenre} selectedDiscoverGenre={selectedDiscoverGenre}
filteredCatalogs={filteredCatalogs} filteredCatalogs={filteredCatalogs}
availableGenres={availableGenres} availableGenres={availableGenres}
availableTypes={availableTypes}
onTypeSelect={handleTypeSelect} onTypeSelect={handleTypeSelect}
onCatalogSelect={handleCatalogSelect} onCatalogSelect={handleCatalogSelect}
onGenreSelect={handleGenreSelect} onGenreSelect={handleGenreSelect}

View file

@ -52,6 +52,8 @@ export interface StreamingAddon {
export interface AddonSearchResults { export interface AddonSearchResults {
addonId: string; addonId: string;
addonName: string; addonName: string;
sectionName: string; // Display name — catalog name for named catalogs, addon name otherwise
catalogIndex: number; // Position in addon manifest — used for deterministic sort within same addon
results: StreamingContent[]; results: StreamingContent[];
} }
@ -363,10 +365,53 @@ class CatalogService {
} }
private canBrowseCatalog(catalog: StreamingCatalog): boolean { private canBrowseCatalog(catalog: StreamingCatalog): boolean {
// Exclude search-only catalogs from discover browsing
if (
(catalog.id && catalog.id.startsWith('search.')) ||
(catalog.type && catalog.type.startsWith('search'))
) {
return false;
}
const requiredExtras = this.getRequiredCatalogExtras(catalog); const requiredExtras = this.getRequiredCatalogExtras(catalog);
return requiredExtras.every(extraName => extraName === 'genre'); return requiredExtras.every(extraName => extraName === 'genre');
} }
/**
* Whether a catalog should appear on the home screen, based purely on the
* addon manifest no user settings / mmkv involved.
*
* Rules (in order):
* 1. Search catalogs (id/type starts with "search") never on home
* 2. Catalogs with any required extra (including required genre) never on home
* 3. Addon uses showInHome flag on at least one catalog:
* only catalogs with showInHome:true appear on home
* 4. No showInHome flag on any catalog all browseable catalogs appear on home
*/
private isVisibleOnHome(catalog: StreamingCatalog, addonCatalogs: StreamingCatalog[]): boolean {
// Rule 1: never show search catalogs
if (
(catalog.id && catalog.id.startsWith('search.')) ||
(catalog.type && catalog.type.startsWith('search'))
) {
return false;
}
// Rule 2: never show catalogs with any required extra (e.g. required genre, calendarVideosIds)
const requiredExtras = this.getRequiredCatalogExtras(catalog);
if (requiredExtras.length > 0) {
return false;
}
// Rule 3: respect showInHome if the addon uses it on any catalog
const addonUsesShowInHome = addonCatalogs.some((c: any) => c.showInHome === true);
if (addonUsesShowInHome) {
return (catalog as any).showInHome === true;
}
// Rule 4: no showInHome flag used — show all browseable catalogs
return true;
}
private canSearchCatalog(catalog: StreamingCatalog): boolean { private canSearchCatalog(catalog: StreamingCatalog): boolean {
if (!this.catalogSupportsExtra(catalog, 'search')) { if (!this.catalogSupportsExtra(catalog, 'search')) {
return false; return false;
@ -379,24 +424,13 @@ class CatalogService {
async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> { async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> {
const addons = await this.getAllAddons(); const addons = await this.getAllAddons();
// Load enabled/disabled settings // Collect catalogs visible on home using manifest-only rules (no mmkv/user settings)
const catalogSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
// Collect all potential catalogs first
const potentialCatalogs: { addon: StreamingAddon; catalog: any }[] = []; const potentialCatalogs: { addon: StreamingAddon; catalog: any }[] = [];
for (const addon of addons) { for (const addon of addons) {
if (addon.catalogs) { if (addon.catalogs) {
for (const catalog of addon.catalogs) { for (const catalog of addon.catalogs) {
if (!this.canBrowseCatalog(catalog)) { if (this.isVisibleOnHome(catalog, addon.catalogs)) {
continue;
}
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
const isEnabled = catalogSettings[settingKey] ?? true;
if (isEnabled) {
potentialCatalogs.push({ addon, catalog }); potentialCatalogs.push({ addon, catalog });
} }
} }
@ -510,7 +544,9 @@ class CatalogService {
const catalogPromises: Promise<CatalogContent | null>[] = []; const catalogPromises: Promise<CatalogContent | null>[] = [];
for (const addon of typeAddons) { for (const addon of typeAddons) {
const typeCatalogs = addon.catalogs.filter(catalog => catalog.type === type); const typeCatalogs = addon.catalogs.filter((catalog: StreamingCatalog) =>
catalog.type === type && this.isVisibleOnHome(catalog, addon.catalogs)
);
for (const catalog of typeCatalogs) { for (const catalog of typeCatalogs) {
const catalogPromise = (async () => { const catalogPromise = (async () => {
@ -1496,6 +1532,10 @@ class CatalogService {
return; return;
} }
// Build addon order map for deterministic section sorting
const addonOrderRef: Record<string, number> = {};
searchableAddons.forEach((addon, i) => { addonOrderRef[addon.id] = i; });
// Global dedupe across emitted results // Global dedupe across emitted results
const globalSeen = new Set<string>(); const globalSeen = new Set<string>();
@ -1503,7 +1543,6 @@ class CatalogService {
searchableAddons.map(async (addon) => { searchableAddons.map(async (addon) => {
if (controller.cancelled) return; if (controller.cancelled) return;
try { try {
// Get the manifest to ensure we have the correct URL
const manifest = manifestMap.get(addon.id); const manifest = manifestMap.get(addon.id);
if (!manifest) { if (!manifest) {
logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`); logger.warn(`Manifest not found for addon ${addon.name} (${addon.id})`);
@ -1511,7 +1550,6 @@ class CatalogService {
} }
const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog)); const searchableCatalogs = (addon.catalogs || []).filter(catalog => this.canSearchCatalog(catalog));
logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`); logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`);
// Fetch all catalogs for this addon in parallel // Fetch all catalogs for this addon in parallel
@ -1520,33 +1558,114 @@ class CatalogService {
); );
if (controller.cancelled) return; if (controller.cancelled) return;
const addonResults: StreamingContent[] = []; // If addon has multiple search catalogs, emit each as its own section.
for (const s of settled) { // If only one, emit as a single addon section (original behaviour).
if (s.status === 'fulfilled' && Array.isArray(s.value)) { const hasMultipleCatalogs = searchableCatalogs.length > 1;
addonResults.push(...s.value);
const catalogResultsList: { catalog: any; results: StreamingContent[] }[] = [];
for (let i = 0; i < searchableCatalogs.length; i++) {
const s = settled[i];
if (s.status === 'fulfilled' && Array.isArray(s.value) && s.value.length > 0) {
catalogResultsList.push({ catalog: searchableCatalogs[i], results: s.value });
} else if (s.status === 'rejected') { } else if (s.status === 'rejected') {
logger.warn(`Search failed for catalog in ${addon.name}:`, s.reason); logger.warn(`Search failed for catalog ${searchableCatalogs[i].id} in ${addon.name}:`, s.reason);
} }
} }
if (addonResults.length === 0) { if (catalogResultsList.length === 0) {
logger.log(`No results from ${addon.name}`); logger.log(`No results from ${addon.name}`);
return; return;
} }
// Dedupe within addon and against global if (hasMultipleCatalogs) {
const localSeen = new Set<string>(); // Human-readable labels for known content types used as fallback section names
const unique = addonResults.filter(item => { const CATALOG_TYPE_LABELS: Record<string, string> = {
const key = `${item.type}:${item.id}`; 'movie': 'Movies',
if (localSeen.has(key) || globalSeen.has(key)) return false; 'series': 'TV Shows',
localSeen.add(key); 'anime.series': 'Anime Series',
globalSeen.add(key); 'anime.movie': 'Anime Movies',
return true; 'other': 'Other',
}); 'tv': 'TV',
'channel': 'Channels',
};
if (unique.length > 0 && !controller.cancelled) { // Emit each catalog as its own section, in manifest order
logger.log(`Emitting ${unique.length} results from ${addon.name}`); for (let ci = 0; ci < catalogResultsList.length; ci++) {
onAddonResults({ addonId: addon.id, addonName: addon.name, results: unique }); const { catalog, results } = catalogResultsList[ci];
if (controller.cancelled) return;
// Within-catalog dedup: prefer dot-type over generic for same ID
const bestById = new Map<string, StreamingContent>();
for (const item of results) {
const existing = bestById.get(item.id);
if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) {
bestById.set(item.id, item);
}
}
// Stamp catalog type onto results
const stamped = Array.from(bestById.values()).map(item => {
if (catalog.type && item.type !== catalog.type) {
return { ...item, type: catalog.type };
}
return item;
});
// Dedupe against global seen
const unique = stamped.filter(item => {
const key = `${item.type}:${item.id}`;
if (globalSeen.has(key)) return false;
globalSeen.add(key);
return true;
});
if (unique.length > 0 && !controller.cancelled) {
// Build section name:
// - If catalog.name is generic ("Search") or same as addon name, use type label instead
// - Otherwise use catalog.name as-is
const GENERIC_NAMES = new Set(['search', 'Search']);
const typeLabel = CATALOG_TYPE_LABELS[catalog.type]
|| catalog.type.replace(/[._]/g, ' ').replace(/\w/g, (c: string) => c.toUpperCase());
const catalogLabel = (!catalog.name || GENERIC_NAMES.has(catalog.name) || catalog.name === addon.name)
? typeLabel
: catalog.name;
const sectionName = `${addon.name} - ${catalogLabel}`;
// catalogIndex encodes addon rank + position within addon for deterministic ordering
const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
const catalogIndex = addonRank * 1000 + ci;
logger.log(`Emitting ${unique.length} results from ${sectionName}`);
onAddonResults({ addonId: `${addon.id}||${catalog.id}`, addonName: addon.name, sectionName, catalogIndex, results: unique });
}
}
} else {
// Single catalog — one section per addon
const allResults = catalogResultsList.flatMap(c => c.results);
const bestByIdWithinAddon = new Map<string, StreamingContent>();
for (const item of allResults) {
const existing = bestByIdWithinAddon.get(item.id);
if (!existing || (!existing.type.includes('.') && item.type.includes('.'))) {
bestByIdWithinAddon.set(item.id, item);
}
}
const deduped = Array.from(bestByIdWithinAddon.values());
const localSeen = new Set<string>();
const unique = deduped.filter(item => {
const key = `${item.type}:${item.id}`;
if (localSeen.has(key) || globalSeen.has(key)) return false;
localSeen.add(key);
globalSeen.add(key);
return true;
});
if (unique.length > 0 && !controller.cancelled) {
const addonRank = addonOrderRef[addon.id] ?? Number.MAX_SAFE_INTEGER;
logger.log(`Emitting ${unique.length} results from ${addon.name}`);
onAddonResults({ addonId: addon.id, addonName: addon.name, sectionName: addon.name, catalogIndex: addonRank * 1000, results: unique });
}
} }
} catch (e) { } catch (e) {
logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e); logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e);
@ -1626,6 +1745,12 @@ class CatalogService {
const items = metas.map(meta => { const items = metas.map(meta => {
const content = this.convertMetaToStreamingContent(meta); const content = this.convertMetaToStreamingContent(meta);
content.addonId = manifest.id; content.addonId = manifest.id;
// The meta's own type field may be generic (e.g. "series") even when
// the catalog it came from is more specific (e.g. "anime.series").
// Stamp the catalog type so grouping in the UI is correct.
if (type && content.type !== type) {
content.type = type;
}
return content; return content;
}); });
logger.log(`Found ${items.length} results from ${manifest.name}`); logger.log(`Found ${items.length} results from ${manifest.name}`);

View file

@ -335,7 +335,6 @@ class StremioService {
if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) return true; if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) return true;
return lowerId.length > lowerPrefix.length; return lowerId.length > lowerPrefix.length;
}); });
if (__DEV__) console.log(`🔍 [isValidContentId] Prefix match result: ${result} for ID '${id}'`);
return result; return result;
} }

View file

@ -1135,8 +1135,16 @@ class SupabaseSyncService {
} }
private async isExternalProgressSyncConnected(): Promise<boolean> { private async isExternalProgressSyncConnected(): Promise<boolean> {
if (await this.isTraktConnected()) return true; const trakt = await this.isTraktConnected();
return await this.isSimklConnected(); if (trakt) {
logger.log('[SupabaseSyncService] isExternalProgressSyncConnected: Trakt is connected, returning true');
return true;
}
const simkl = await this.isSimklConnected();
if (simkl) {
logger.log('[SupabaseSyncService] isExternalProgressSyncConnected: Simkl is connected, returning true');
}
return simkl;
} }
private async pullPluginsToLocal(): Promise<void> { private async pullPluginsToLocal(): Promise<void> {
@ -1409,62 +1417,90 @@ class SupabaseSyncService {
private async pushWatchProgressFromLocal(): Promise<void> { private async pushWatchProgressFromLocal(): Promise<void> {
const all = await storageService.getAllWatchProgress(); const all = await storageService.getAllWatchProgress();
const allKeys = Object.keys(all);
const nextSeenKeys = new Set<string>(); const nextSeenKeys = new Set<string>();
const changedEntries: Array<{ key: string; row: WatchProgressRow; signature: string }> = []; const changedEntries: Array<{ key: string; row: WatchProgressRow; signature: string }> = [];
let skippedSameSignature = 0;
let skippedParseFailure = 0;
for (const [key, value] of Object.entries(all)) { for (const [key, value] of Object.entries(all)) {
nextSeenKeys.add(key); nextSeenKeys.add(key);
const signature = this.getWatchProgressEntrySignature(value); const signature = this.getWatchProgressEntrySignature(value);
if (this.watchProgressPushedSignatures.get(key) === signature) { if (this.watchProgressPushedSignatures.get(key) === signature) {
skippedSameSignature++;
continue; continue;
} }
const parsed = this.parseWatchProgressKey(key); const parsed = this.parseWatchProgressKey(key);
if (!parsed) { if (!parsed) {
skippedParseFailure++;
continue; continue;
} }
changedEntries.push({ const row: WatchProgressRow = {
key, content_id: parsed.contentId,
signature, content_type: parsed.contentType,
row: { video_id: parsed.videoId,
content_id: parsed.contentId, season: parsed.season,
content_type: parsed.contentType, episode: parsed.episode,
video_id: parsed.videoId, position: this.secondsToMsLong(value.currentTime),
season: parsed.season, duration: this.secondsToMsLong(value.duration),
episode: parsed.episode, last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()),
position: this.secondsToMsLong(value.currentTime), progress_key: parsed.progressKey,
duration: this.secondsToMsLong(value.duration), };
last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()),
progress_key: parsed.progressKey, changedEntries.push({ key, signature, row });
},
});
} }
// Prune signatures for entries no longer present locally (deletes are handled separately). // Prune signatures for entries no longer present locally (deletes are handled separately).
let prunedSignatures = 0;
for (const existingKey of Array.from(this.watchProgressPushedSignatures.keys())) { for (const existingKey of Array.from(this.watchProgressPushedSignatures.keys())) {
if (!nextSeenKeys.has(existingKey)) { if (!nextSeenKeys.has(existingKey)) {
this.watchProgressPushedSignatures.delete(existingKey); this.watchProgressPushedSignatures.delete(existingKey);
prunedSignatures++;
} }
} }
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: skippedSameSignature=${skippedSameSignature} skippedParseFailure=${skippedParseFailure} prunedStaleSignatures=${prunedSignatures}`);
if (changedEntries.length === 0) { if (changedEntries.length === 0) {
logger.log('[SupabaseSyncService] pushWatchProgressFromLocal: no changed entries; skipping push'); logger.log('[SupabaseSyncService] pushWatchProgressFromLocal: no changed entries; skipping push');
return; return;
} }
await this.callRpc<void>('sync_push_watch_progress', { const rpcPayload = changedEntries.map((entry) => entry.row);
p_entries: changedEntries.map((entry) => entry.row), logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: calling sync_push_watch_progress with ${rpcPayload.length} entries`);
}); try {
await this.callRpc<void>('sync_push_watch_progress', {
p_entries: rpcPayload,
});
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: RPC success`);
} catch (rpcError: any) {
logger.error(`[SupabaseSyncService] pushWatchProgressFromLocal: RPC FAILED`, rpcError?.message || rpcError);
throw rpcError;
}
for (const entry of changedEntries) { for (const entry of changedEntries) {
this.watchProgressPushedSignatures.set(entry.key, entry.signature); this.watchProgressPushedSignatures.set(entry.key, entry.signature);
} }
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: pushedChanged=${changedEntries.length} totalLocal=${Object.keys(all).length}`); logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: pushedChanged=${changedEntries.length} totalLocal=${allKeys.length}`);
} }
private async pullLibraryToLocal(): Promise<void> { private async pullLibraryToLocal(): Promise<void> {
const rows = await this.callRpc<LibraryRow[]>('sync_pull_library', {}); const PAGE_SIZE = 500;
const rows: LibraryRow[] = [];
let offset = 0;
while (true) {
const page = await this.callRpc<LibraryRow[]>('sync_pull_library', {
p_limit: PAGE_SIZE,
p_offset: offset,
});
if (!page || page.length === 0) break;
rows.push(...page);
if (page.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
const localItems = await catalogService.getLibraryItems(); const localItems = await catalogService.getLibraryItems();
const existing = new Set(localItems.map((item) => `${item.type}:${item.id}`)); const existing = new Set(localItems.map((item) => `${item.type}:${item.id}`));
const remoteSet = new Set<string>(); const remoteSet = new Set<string>();
@ -1532,6 +1568,8 @@ class SupabaseSyncService {
private async pushWatchedItemsFromLocal(): Promise<void> { private async pushWatchedItemsFromLocal(): Promise<void> {
const items = await watchedService.getAllWatchedItems(); const items = await watchedService.getAllWatchedItems();
if (items.length === 0) return;
const payload: WatchedRow[] = items.map((item) => ({ const payload: WatchedRow[] = items.map((item) => ({
content_id: item.content_id, content_id: item.content_id,
content_type: item.content_type, content_type: item.content_type,
@ -1540,7 +1578,13 @@ class SupabaseSyncService {
episode: item.episode, episode: item.episode,
watched_at: item.watched_at, watched_at: item.watched_at,
})); }));
await this.callRpc<void>('sync_push_watched_items', { p_items: payload });
try {
await this.callRpc<void>('sync_push_watched_items', { p_items: payload });
} catch (rpcError: any) {
logger.error(`[SupabaseSyncService] pushWatchedItemsFromLocal: RPC FAILED`, rpcError?.message || rpcError);
throw rpcError;
}
} }
} }

View file

@ -175,13 +175,22 @@ class WatchedService {
.filter((item) => Boolean(item.content_id)); .filter((item) => Boolean(item.content_id));
// Guard: do not wipe local watched data if backend temporarily returns empty. // Guard: do not wipe local watched data if backend temporarily returns empty.
if (normalizedRemote.length === 0) { if (normalizedRemote.length === 0) {
logger.log('[WatchedService] reconcileRemoteWatchedItems: remote is empty, doing nothing');
return; return;
} }
const currentLocal = await this.loadWatchedItems();
const remoteKeys = new Set(normalizedRemote.map(r => this.watchedKey(r)));
// Find local items that need to be removed because they don't exist remotely
const toRemove = currentLocal.filter(l => !remoteKeys.has(this.watchedKey(l)));
await this.saveWatchedItems(normalizedRemote); await this.saveWatchedItems(normalizedRemote);
this.notifyWatchedSubscribers(); this.notifyWatchedSubscribers();
// 1. Set watched status for all remote items
for (const item of normalizedRemote) { for (const item of normalizedRemote) {
if (item.content_type === 'movie') { if (item.content_type === 'movie') {
await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at)); await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at));
@ -192,8 +201,27 @@ class WatchedService {
const episodeId = `${item.content_id}:${item.season}:${item.episode}`; const episodeId = `${item.content_id}:${item.season}:${item.episode}`;
await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at)); await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at));
} }
// 2. Unset watched status for local items that were deleted remotely
for (const item of toRemove) {
if (item.content_type === 'movie') {
await this.setLocalWatchedStatus(item.content_id, 'movie', false);
} else if (item.season != null && item.episode != null) {
const episodeId = `${item.content_id}:${item.season}:${item.episode}`;
await this.setLocalWatchedStatus(item.content_id, 'series', false, episodeId);
}
}
if (toRemove.length > 0) {
logger.log(`[WatchedService] reconcileRemoteWatchedItems: Removed ${toRemove.length} local items that were deleted remotely`);
}
} }
/**
* Mark a movie as watched
* @param imdbId - The IMDb ID of the movie
* @param watchedAt - Optional date when watched
*/
/** /**
* Mark a movie as watched * Mark a movie as watched
* @param imdbId - The IMDb ID of the movie * @param imdbId - The IMDb ID of the movie
@ -207,7 +235,7 @@ class WatchedService {
title?: string title?: string
): Promise<{ success: boolean; syncedToTrakt: boolean }> { ): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try { try {
logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`); logger.log(`[WatchedService] Marking movie as watched: ${imdbId} (${title || 'No title'})`);
const isTraktAuth = await this.traktService.isAuthenticated(); const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false; let syncedToTrakt = false;
@ -247,7 +275,7 @@ class WatchedService {
{ {
content_id: imdbId, content_id: imdbId,
content_type: 'movie', content_type: 'movie',
title: imdbId, title: title || imdbId,
season: null, season: null,
episode: null, episode: null,
watched_at: watchedAt.getTime(), watched_at: watchedAt.getTime(),
@ -373,7 +401,7 @@ class WatchedService {
{ {
content_id: showImdbId, content_id: showImdbId,
content_type: 'series', content_type: 'series',
title: showImdbId, title: showTitle || showImdbId,
season, season,
episode, episode,
watched_at: watchedAt.getTime(), watched_at: watchedAt.getTime(),
@ -398,7 +426,8 @@ class WatchedService {
showImdbId: string, showImdbId: string,
showId: string, showId: string,
episodes: Array<{ season: number; episode: number }>, episodes: Array<{ season: number; episode: number }>,
watchedAt: Date = new Date() watchedAt: Date = new Date(),
showTitle?: string
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> { ): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
try { try {
if (episodes.length === 0) { if (episodes.length === 0) {
@ -479,7 +508,8 @@ class WatchedService {
showId: string, showId: string,
season: number, season: number,
episodeNumbers: number[], episodeNumbers: number[],
watchedAt: Date = new Date() watchedAt: Date = new Date(),
showTitle?: string
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> { ): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
try { try {
logger.log(`[WatchedService] Marking season ${season} as watched for ${showImdbId}`); logger.log(`[WatchedService] Marking season ${season} as watched for ${showImdbId}`);
@ -525,7 +555,7 @@ class WatchedService {
episodeNumbers.map((episode) => ({ episodeNumbers.map((episode) => ({
content_id: showImdbId, content_id: showImdbId,
content_type: 'series' as const, content_type: 'series' as const,
title: showImdbId, title: showTitle || showImdbId,
season, season,
episode, episode,
watched_at: watchedAt.getTime(), watched_at: watchedAt.getTime(),

View file

@ -1,7 +1,7 @@
// Single source of truth for the app version displayed in Settings // Single source of truth for the app version displayed in Settings
// Update this when bumping app version // Update this when bumping app version
export const APP_VERSION = '1.4.2'; export const APP_VERSION = '1.4.4';
export function getDisplayedAppVersion(): string { export function getDisplayedAppVersion(): string {
return APP_VERSION; return APP_VERSION;