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'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 38
versionName "1.4.2"
versionCode 40
versionName "1.4.4"
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]
applicationVariants.all { variant ->
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 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_status_bar_translucent" translatable="false">false</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>

View file

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

View file

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

View file

@ -111,6 +111,10 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
this.player = player
playerView.player = player
player?.addListener(playerListener)
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
}
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
else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
if (playerView.width > 0 && playerView.height > 0) {
playerView.resizeMode = resizeMode
} else {
pendingResizeMode = resizeMode
}
pendingResizeMode = resizeMode
playerView.resizeMode = resizeMode
playerView.requestLayout()
requestLayout()
// Re-assert subtitle rendering mode for the current style.
updateSubtitleRenderingMode()
@ -198,7 +201,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
val replacementPlayerView = createPlayerView(viewType).apply {
layoutParams = previousLayoutParams
resizeMode = previousResizeMode
resizeMode = pendingResizeMode ?: previousResizeMode
useController = previousUseController
controllerAutoShow = previousControllerAutoShow
controllerHideOnTouch = previousControllerHideOnTouch
@ -350,7 +353,11 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
fun invalidateAspectRatio() {
playerView.post {
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
playerView.requestLayout()
requestLayout()
}
}
@ -364,20 +371,45 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
playerView.post {
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
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) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
if (width > 0 && height > 0) {
private val layoutRunnable = Runnable {
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
)
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 ->
playerView.resizeMode = resizeMode
}
// Re-apply bottomPaddingFraction once we have a concrete height.
updateSubtitleRenderingMode()
applySubtitleStyle(localStyle)
}
@ -399,7 +431,7 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
setShowSubtitleButton(showSubtitleButton)
setUseArtwork(false)
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) {
(videoSurfaceView as? SurfaceView)?.setSecure(true)

View file

@ -732,7 +732,7 @@ public class ReactExoplayerView extends FrameLayout implements
DefaultRenderersFactory renderersFactory =
new DefaultRenderersFactory(getContext())
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
.setEnableDecoderFallback(true)
.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': {
const targetWatched = !isWatched;
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
if (targetWatched) {
try {
await storageService.setWatchProgress(
item.id,
item.type,
{ currentTime: 1, duration: 1, lastUpdated: Date.now() },
undefined,
{ forceNotify: true, forceWrite: true }
);
} catch { }
if (item.type === 'movie') {
try {
const trakt = TraktService.getInstance();
if (await trakt.isAuthenticated()) {
await trakt.addToWatchedMovies(item.id);
try {
await storageService.updateTraktSyncStatus(item.id, item.type, true, 100);
} catch { }
}
} catch { }
// Use the centralized watchedService to handle all the sync logic (Supabase, Trakt, Simkl, MAL)
import('../../services/watchedService').then(({ watchedService }) => {
if (targetWatched) {
if (item.type === 'movie') {
// Pass the title so it correctly populates the database instead of defaulting to IMDb ID
watchedService.markMovieAsWatched(item.id, new Date(), undefined, undefined, item.name);
} else {
// For series from the homescreen drop-down, we mark S1E1 as watched as a baseline
watchedService.markEpisodeAsWatched(item.id, item.id, 1, 1, new Date(), undefined, item.name);
}
} else {
if (item.type === 'movie') {
watchedService.unmarkMovieAsWatched(item.id);
} else {
// Unmarking a series from the top level is tricky as we don't know the exact episodes.
// For safety and consistency with old behavior, we just clear the legacy flag.
mmkvStorage.removeItem(`watched:${item.type}:${item.id}`);
}
}
}
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);
break;
}

View file

@ -14,6 +14,24 @@ interface AddonSectionProps {
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(({
addonGroup,
addonIndex,
@ -23,25 +41,34 @@ export const AddonSection = React.memo(({
}: AddonSectionProps) => {
const { t } = useTranslation();
const movieResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'movie'),
[addonGroup.results]
);
const seriesResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'series'),
[addonGroup.results]
);
const otherResults = useMemo(() =>
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'),
[addonGroup.results]
);
// Group results by their exact type, preserving order of first appearance
const groupedByType = useMemo(() => {
const order: string[] = [];
const groups: Record<string, StreamingContent[]> = {};
for (const item of addonGroup.results) {
if (!groups[item.type]) {
order.push(item.type);
groups[item.type] = [];
}
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 (
<View>
{/* Addon Header */}
<View style={styles.addonHeaderContainer}>
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
{addonGroup.addonName}
{(addonGroup as any).sectionName || addonGroup.addonName}
</Text>
<View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}>
@ -50,22 +77,13 @@ export const AddonSection = React.memo(({
</View>
</View>
{/* Movies */}
{movieResults.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.movies')} ({movieResults.length})
{groupedByType.map(({ type, items }) => (
<View key={type} style={[styles.carouselContainer, containerStyle]}>
<Text style={[styles.carouselSubtitle, subtitleStyle(currentTheme)]}>
{getLabelForType(type)} ({items.length})
</Text>
<FlatList
data={movieResults}
data={items}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
@ -74,81 +92,16 @@ export const AddonSection = React.memo(({
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`}
keyExtractor={item => `${addonGroup.addonId}-${type}-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</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>
);
}, (prev, next) => {
// Only re-render if this section's reference changed
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
});

View file

@ -11,12 +11,13 @@ interface DiscoverBottomSheetsProps {
typeSheetRef: RefObject<BottomSheetModal>;
catalogSheetRef: RefObject<BottomSheetModal>;
genreSheetRef: RefObject<BottomSheetModal>;
selectedDiscoverType: 'movie' | 'series';
selectedDiscoverType: string;
selectedCatalog: DiscoverCatalog | null;
selectedDiscoverGenre: string | null;
filteredCatalogs: DiscoverCatalog[];
availableGenres: string[];
onTypeSelect: (type: 'movie' | 'series') => void;
availableTypes: string[];
onTypeSelect: (type: string) => void;
onCatalogSelect: (catalog: DiscoverCatalog) => void;
onGenreSelect: (genre: string | null) => void;
currentTheme: any;
@ -31,6 +32,7 @@ export const DiscoverBottomSheets = ({
selectedDiscoverGenre,
filteredCatalogs,
availableGenres,
availableTypes,
onTypeSelect,
onCatalogSelect,
onGenreSelect,
@ -38,7 +40,20 @@ export const DiscoverBottomSheets = ({
}: DiscoverBottomSheetsProps) => {
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 genreSnapPoints = useMemo(() => ['50%'], []);
const [activeBottomSheetRef, setActiveBottomSheetRef] = useState(null);
@ -225,47 +240,25 @@ export const DiscoverBottomSheets = ({
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent}
>
{/* Movies option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
selectedDiscoverType === 'movie' && styles.bottomSheetItemSelected
]}
onPress={() => onTypeSelect('movie')}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{t('search.movies')}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{t('search.browse_movies')}
</Text>
</View>
{selectedDiscoverType === 'movie' && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</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>
{availableTypes.map((type) => (
<TouchableOpacity
key={type}
style={[
styles.bottomSheetItem,
selectedDiscoverType === type && styles.bottomSheetItemSelected
]}
onPress={() => onTypeSelect(type)}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{getLabelForType(type)}
</Text>
</View>
{selectedDiscoverType === type && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
))}
</BottomSheetScrollView>
</BottomSheetModal>
</>

View file

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

View file

@ -114,6 +114,13 @@ interface UseMetadataReturn {
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -427,7 +434,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
return;
}
// Check cache first
const cachedCast = cacheService.getCast(id, type);
const cachedCast = cacheService.getCast(id, normalizedType);
if (cachedCast) {
if (__DEV__) logger.log('[loadCast] Using cached cast data');
setCast(cachedCast);
@ -439,7 +446,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (id.startsWith('tmdb:')) {
const tmdbId = id.split(':')[1];
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) {
const formattedCast = castData.cast.map((actor: any) => ({
id: actor.id,
@ -464,7 +471,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (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) {
const formattedCast = castData.cast.map((actor: any) => ({
id: actor.id,
@ -511,7 +518,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setLoadAttempts(prev => prev + 1);
// Check metadata screen cache
const cachedScreen = cacheService.getMetadataScreen(id, type);
const cachedScreen = cacheService.getMetadataScreen(id, normalizedType);
if (cachedScreen) {
console.log('🔍 [useMetadata] Using cached metadata:', {
id,
@ -523,7 +530,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
});
setMetadata(cachedScreen.metadata);
setCast(cachedScreen.cast);
if (type === 'series' && cachedScreen.episodes) {
if (normalizedType === 'series' && cachedScreen.episodes) {
setGroupedEpisodes(cachedScreen.episodes.groupedEpisodes);
setEpisodes(cachedScreen.episodes.currentEpisodes);
setSelectedSeason(cachedScreen.episodes.selectedSeason);
@ -567,7 +574,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
} else {
const tmdbId = id.split(':')[1];
// 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);
const movieDetails = await tmdbService.getMovieDetails(
tmdbId,
@ -639,7 +646,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
setMetadata(formattedMovie);
cacheService.setMetadata(id, type, formattedMovie);
cacheService.setMetadata(id, normalizedType, formattedMovie);
(async () => {
const items = await catalogService.getLibraryItems();
const isInLib = items.some(item => item.id === id);
@ -649,7 +656,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
return;
}
}
} else if (type === 'series') {
} else if (normalizedType === 'series') {
// Handle TV shows with TMDB IDs
if (__DEV__) logger.log('Fetching TV show details from TMDB for:', tmdbId);
try {
@ -719,7 +726,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
setMetadata(formattedShow);
cacheService.setMetadata(id, type, formattedShow);
cacheService.setMetadata(id, normalizedType, formattedShow);
// Load series data (episodes)
setTmdbId(parseInt(tmdbId));
@ -779,7 +786,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
for (const addon of externalMetaAddons) {
try {
const result = await withTimeout(
stremioService.getMetaDetails(type, actualId, addon.id),
stremioService.getMetaDetails(normalizedType, actualId, addon.id),
API_TIMEOUT
);
@ -799,7 +806,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// If no external addon worked, fall back to catalog addon
console.log('🔍 [useMetadata] No external meta addon worked, falling back to catalog addon');
const result = await withTimeout(
catalogService.getEnhancedContentDetails(type, actualId, addonId),
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
API_TIMEOUT
);
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:', { type, actualId, addonId });
const result = await withTimeout(
catalogService.getEnhancedContentDetails(type, actualId, addonId),
catalogService.getEnhancedContentDetails(normalizedType, actualId, addonId),
API_TIMEOUT
);
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');
const tmdbRaw = id.split(':')[1];
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) {
console.log('🔍 [useMetadata] Trying converted ID:', { originalId: id, convertedId: stremioId });
const [content, castData] = await Promise.allSettled([
withRetry(async () => {
const result = await withTimeout(
catalogService.getEnhancedContentDetails(type, stremioId, addonId),
catalogService.getEnhancedContentDetails(normalizedType, stremioId, addonId),
API_TIMEOUT
);
if (stremioId.startsWith('tt')) {
@ -934,7 +941,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (finalTmdbId) {
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
if (type === 'movie') {
if (normalizedType === 'movie') {
const localized = await tmdbSvc.getMovieDetails(String(finalTmdbId), lang);
if (localized) {
const movieDetailsObj = {
@ -1011,7 +1018,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) {
const tmdbService = TMDBService.getInstance();
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
const contentType = type === 'series' ? 'tv' : 'movie';
const contentType = normalizedType === 'series' ? 'tv' : 'movie';
// Get TMDB ID
let tmdbIdForLogo = null;
@ -1080,7 +1087,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
return updated;
});
cacheService.setMetadata(id, type, finalMetadata);
cacheService.setMetadata(id, normalizedType, finalMetadata);
(async () => {
const items = await catalogService.getLibraryItems();
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)
try {
let externalIds = null;
if (type === 'movie') {
if (normalizedType === 'movie') {
const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT);
externalIds = movieDetails?.external_ids;
} else if (type === 'series') {
} else if (normalizedType === 'series') {
externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
}
@ -1829,7 +1836,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
return false;
});
const requestedEpisodeType = type;
const requestedEpisodeType = normalizedType;
let streamAddons = pickStreamCapableAddons(requestedEpisodeType);
if (streamAddons.length === 0) {
@ -1917,13 +1924,16 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const cleanEpisodeId = episodeId.replace(/^series:/, '');
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
showIdStr = parts[0];
seasonNum = parts[1];
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)
showIdStr = `${parts[0]}:${parts[1]}`;
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);
// Ensure consistent format
// Ensure consistent format or fallback to episodeId if parsing failed
// This handles cases where 'tt' is used for a unique episode ID directly
// 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
// on a tt-identified show), use showIdStr so we request via the correct namespace.
if (!seasonNum && !episodeNum) {
stremioEpisodeId = episodeId;
} else if (!seasonNum) {
// No season (e.g., mal:57658:1) - use id:episode format
stremioEpisodeId = `${id}:${episodeNum}`;
// No season (e.g., kitsu:48363:8, mal:57658:1)
const baseId = showIdStr && showIdStr !== id ? showIdStr : id;
stremioEpisodeId = `${baseId}:${episodeNum}`;
} 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);
} 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
stremioEpisodeId = episodeId.replace(/^series:/, '');
} else if (!seasonNum) {
// No season (e.g., mal:57658:1) - use id:episode format
stremioEpisodeId = `${id}:${episodeNum}`;
// No season (e.g., kitsu:12345:1, mal:57658:1) - use showIdStr:episode format.
// 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 {
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
@ -2111,7 +2128,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (!metadata) return;
if (inLibrary) {
catalogService.removeFromLibrary(type, id);
catalogService.removeFromLibrary(normalizedType, id);
} else {
catalogService.addToLibrary(metadata);
}
@ -2190,12 +2207,12 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
try {
const tmdbService = TMDBService.getInstance();
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)
const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({
id: `tmdb:${item.id}`,
type: type === 'movie' ? 'movie' : 'series',
type: normalizedType === 'movie' ? 'movie' : 'series',
name: item.title || item.name || 'Untitled',
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
@ -2226,7 +2243,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
setTmdbId(fetchedTmdbId);
// Fetch certification only if granular setting is enabled
if (settings.tmdbEnrichCertification) {
const certification = await tmdbService.getCertification(type, fetchedTmdbId);
const certification = await tmdbService.getCertification(normalizedType, fetchedTmdbId);
if (certification) {
if (__DEV__) console.log('[useMetadata] fetched certification via TMDB id (extract path)', { type, fetchedTmdbId, certification });
setMetadata(prev => prev ? {
@ -2299,7 +2316,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
return;
}
const tmdbSvc = TMDBService.getInstance();
const cert = await tmdbSvc.getCertification(type, tmdbId);
const cert = await tmdbSvc.getCertification(normalizedType, tmdbId);
if (cert) {
if (__DEV__) console.log('[useMetadata] fetched certification (attach path)', { type, tmdbId, cert });
setMetadata(prev => prev ? { ...prev, tmdbId, certification: cert } : prev);
@ -2326,7 +2343,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
return;
}
const contentKey = `${type}-${tmdbId}`;
const contentKey = `${normalizedType}-${tmdbId}`;
if (productionInfoFetchedRef.current === contentKey) {
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)
const hasNetworks = !!(metadata as any).networks;
const hasCollection = !!(metadata as any).collection;
if (hasNetworks && (type !== 'movie' || hasCollection)) {
if (hasNetworks && (normalizedType !== 'movie' || hasCollection)) {
return;
}
@ -2357,7 +2374,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
collectionsEnabled: settings.tmdbEnrichCollections
});
if (type === 'series') {
if (normalizedType === 'series') {
// Fetch networks and additional details for TV shows
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
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
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
const movieDetails = await tmdbService.getMovieDetails(String(tmdbId), lang);

View file

@ -244,7 +244,34 @@ const HomeScreen = () => {
for (const addon of addons) {
if (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 isEnabled = catalogSettings[settingKey] ?? true;

View file

@ -84,7 +84,7 @@ const SearchScreen = () => {
// Discover section state
const [discoverCatalogs, setDiscoverCatalogs] = useState<DiscoverCatalog[]>([]);
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 [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
@ -127,7 +127,7 @@ const SearchScreen = () => {
try {
// Load saved type
const savedType = await mmkvStorage.getItem(DISCOVER_TYPE_KEY);
if (savedType && (savedType === 'movie' || savedType === 'series')) {
if (savedType) {
setSelectedDiscoverType(savedType);
}
@ -141,7 +141,7 @@ const SearchScreen = () => {
}, []);
// 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 {
// Save type
await mmkvStorage.setItem(DISCOVER_TYPE_KEY, type);
@ -267,10 +267,8 @@ const SearchScreen = () => {
if (isMounted.current) {
const allCatalogs: DiscoverCatalog[] = [];
for (const [type, catalogs] of Object.entries(filters.catalogsByType)) {
if (type === 'movie' || type === 'series') {
for (const catalog of catalogs) {
allCatalogs.push({ ...catalog, type });
}
for (const catalog of catalogs) {
allCatalogs.push({ ...catalog, type });
}
}
setDiscoverCatalogs(allCatalogs);
@ -517,7 +515,14 @@ const SearchScreen = () => {
setResults(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);
if (existingIndex >= 0) {
@ -526,10 +531,10 @@ const SearchScreen = () => {
return { byAddon: copy, allResults: prev.allResults };
}
const insertRank = getRank(section.addonId);
const insertRank = getRank(section);
let insertAt = prev.byAddon.length;
for (let i = 0; i < prev.byAddon.length; i++) {
if (getRank(prev.byAddon[i].addonId) > insertRank) {
if (getRank(prev.byAddon[i]) > insertRank) {
insertAt = i;
break;
}
@ -636,9 +641,10 @@ const SearchScreen = () => {
};
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 handleTypeSelect = (type: 'movie' | 'series') => {
const handleTypeSelect = (type: string) => {
setSelectedDiscoverType(type);
// Save type setting
@ -893,6 +899,7 @@ const SearchScreen = () => {
selectedDiscoverGenre={selectedDiscoverGenre}
filteredCatalogs={filteredCatalogs}
availableGenres={availableGenres}
availableTypes={availableTypes}
onTypeSelect={handleTypeSelect}
onCatalogSelect={handleCatalogSelect}
onGenreSelect={handleGenreSelect}

View file

@ -52,6 +52,8 @@ export interface StreamingAddon {
export interface AddonSearchResults {
addonId: 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[];
}
@ -363,10 +365,53 @@ class CatalogService {
}
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);
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 {
if (!this.catalogSupportsExtra(catalog, 'search')) {
return false;
@ -379,24 +424,13 @@ class CatalogService {
async resolveHomeCatalogsToFetch(limitIds?: string[]): Promise<{ addon: StreamingAddon; catalog: any }[]> {
const addons = await this.getAllAddons();
// Load enabled/disabled settings
const catalogSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY);
const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
// Collect all potential catalogs first
// Collect catalogs visible on home using manifest-only rules (no mmkv/user settings)
const potentialCatalogs: { addon: StreamingAddon; catalog: any }[] = [];
for (const addon of addons) {
if (addon.catalogs) {
for (const catalog of addon.catalogs) {
if (!this.canBrowseCatalog(catalog)) {
continue;
}
const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
const isEnabled = catalogSettings[settingKey] ?? true;
if (isEnabled) {
if (this.isVisibleOnHome(catalog, addon.catalogs)) {
potentialCatalogs.push({ addon, catalog });
}
}
@ -510,7 +544,9 @@ class CatalogService {
const catalogPromises: Promise<CatalogContent | null>[] = [];
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) {
const catalogPromise = (async () => {
@ -1496,6 +1532,10 @@ class CatalogService {
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
const globalSeen = new Set<string>();
@ -1503,7 +1543,6 @@ class CatalogService {
searchableAddons.map(async (addon) => {
if (controller.cancelled) return;
try {
// Get the manifest to ensure we have the correct URL
const manifest = manifestMap.get(addon.id);
if (!manifest) {
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));
logger.log(`Searching ${addon.name} (${addon.id}) with ${searchableCatalogs.length} searchable catalogs`);
// Fetch all catalogs for this addon in parallel
@ -1520,33 +1558,114 @@ class CatalogService {
);
if (controller.cancelled) return;
const addonResults: StreamingContent[] = [];
for (const s of settled) {
if (s.status === 'fulfilled' && Array.isArray(s.value)) {
addonResults.push(...s.value);
// If addon has multiple search catalogs, emit each as its own section.
// If only one, emit as a single addon section (original behaviour).
const hasMultipleCatalogs = searchableCatalogs.length > 1;
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') {
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}`);
return;
}
// Dedupe within addon and against global
const localSeen = new Set<string>();
const unique = addonResults.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 (hasMultipleCatalogs) {
// Human-readable labels for known content types used as fallback section names
const CATALOG_TYPE_LABELS: Record<string, string> = {
'movie': 'Movies',
'series': 'TV Shows',
'anime.series': 'Anime Series',
'anime.movie': 'Anime Movies',
'other': 'Other',
'tv': 'TV',
'channel': 'Channels',
};
if (unique.length > 0 && !controller.cancelled) {
logger.log(`Emitting ${unique.length} results from ${addon.name}`);
onAddonResults({ addonId: addon.id, addonName: addon.name, results: unique });
// Emit each catalog as its own section, in manifest order
for (let ci = 0; ci < catalogResultsList.length; ci++) {
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) {
logger.error(`Error searching addon ${addon.name} (${addon.id}):`, e);
@ -1626,6 +1745,12 @@ class CatalogService {
const items = metas.map(meta => {
const content = this.convertMetaToStreamingContent(meta);
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;
});
logger.log(`Found ${items.length} results from ${manifest.name}`);

View file

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

View file

@ -1135,8 +1135,16 @@ class SupabaseSyncService {
}
private async isExternalProgressSyncConnected(): Promise<boolean> {
if (await this.isTraktConnected()) return true;
return await this.isSimklConnected();
const trakt = await this.isTraktConnected();
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> {
@ -1409,62 +1417,90 @@ class SupabaseSyncService {
private async pushWatchProgressFromLocal(): Promise<void> {
const all = await storageService.getAllWatchProgress();
const allKeys = Object.keys(all);
const nextSeenKeys = new Set<string>();
const changedEntries: Array<{ key: string; row: WatchProgressRow; signature: string }> = [];
let skippedSameSignature = 0;
let skippedParseFailure = 0;
for (const [key, value] of Object.entries(all)) {
nextSeenKeys.add(key);
const signature = this.getWatchProgressEntrySignature(value);
if (this.watchProgressPushedSignatures.get(key) === signature) {
skippedSameSignature++;
continue;
}
const parsed = this.parseWatchProgressKey(key);
if (!parsed) {
skippedParseFailure++;
continue;
}
changedEntries.push({
key,
signature,
row: {
content_id: parsed.contentId,
content_type: parsed.contentType,
video_id: parsed.videoId,
season: parsed.season,
episode: parsed.episode,
position: this.secondsToMsLong(value.currentTime),
duration: this.secondsToMsLong(value.duration),
last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()),
progress_key: parsed.progressKey,
},
});
const row: WatchProgressRow = {
content_id: parsed.contentId,
content_type: parsed.contentType,
video_id: parsed.videoId,
season: parsed.season,
episode: parsed.episode,
position: this.secondsToMsLong(value.currentTime),
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).
let prunedSignatures = 0;
for (const existingKey of Array.from(this.watchProgressPushedSignatures.keys())) {
if (!nextSeenKeys.has(existingKey)) {
this.watchProgressPushedSignatures.delete(existingKey);
prunedSignatures++;
}
}
logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: skippedSameSignature=${skippedSameSignature} skippedParseFailure=${skippedParseFailure} prunedStaleSignatures=${prunedSignatures}`);
if (changedEntries.length === 0) {
logger.log('[SupabaseSyncService] pushWatchProgressFromLocal: no changed entries; skipping push');
return;
}
await this.callRpc<void>('sync_push_watch_progress', {
p_entries: changedEntries.map((entry) => entry.row),
});
const rpcPayload = 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) {
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> {
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 existing = new Set(localItems.map((item) => `${item.type}:${item.id}`));
const remoteSet = new Set<string>();
@ -1532,6 +1568,8 @@ class SupabaseSyncService {
private async pushWatchedItemsFromLocal(): Promise<void> {
const items = await watchedService.getAllWatchedItems();
if (items.length === 0) return;
const payload: WatchedRow[] = items.map((item) => ({
content_id: item.content_id,
content_type: item.content_type,
@ -1540,7 +1578,13 @@ class SupabaseSyncService {
episode: item.episode,
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));
// Guard: do not wipe local watched data if backend temporarily returns empty.
if (normalizedRemote.length === 0) {
logger.log('[WatchedService] reconcileRemoteWatchedItems: remote is empty, doing nothing');
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);
this.notifyWatchedSubscribers();
// 1. Set watched status for all remote items
for (const item of normalizedRemote) {
if (item.content_type === 'movie') {
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}`;
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
* @param imdbId - The IMDb ID of the movie
@ -207,7 +235,7 @@ class WatchedService {
title?: string
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
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();
let syncedToTrakt = false;
@ -222,10 +250,10 @@ class WatchedService {
if (MalAuth.isAuthenticated()) {
MalSync.scrobbleEpisode(
title || 'Movie', // Use real title or generic fallback
1,
1,
'movie',
undefined,
1,
1,
'movie',
undefined,
imdbId,
undefined,
malId,
@ -247,7 +275,7 @@ class WatchedService {
{
content_id: imdbId,
content_type: 'movie',
title: imdbId,
title: title || imdbId,
season: null,
episode: null,
watched_at: watchedAt.getTime(),
@ -342,7 +370,7 @@ class WatchedService {
'series',
season,
showImdbId,
releaseDate,
releaseDate,
malId,
dayIndex,
tmdbId
@ -373,7 +401,7 @@ class WatchedService {
{
content_id: showImdbId,
content_type: 'series',
title: showImdbId,
title: showTitle || showImdbId,
season,
episode,
watched_at: watchedAt.getTime(),
@ -398,7 +426,8 @@ class WatchedService {
showImdbId: string,
showId: string,
episodes: Array<{ season: number; episode: number }>,
watchedAt: Date = new Date()
watchedAt: Date = new Date(),
showTitle?: string
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
try {
if (episodes.length === 0) {
@ -479,7 +508,8 @@ class WatchedService {
showId: string,
season: number,
episodeNumbers: number[],
watchedAt: Date = new Date()
watchedAt: Date = new Date(),
showTitle?: string
): Promise<{ success: boolean; syncedToTrakt: boolean; count: number }> {
try {
logger.log(`[WatchedService] Marking season ${season} as watched for ${showImdbId}`);
@ -525,7 +555,7 @@ class WatchedService {
episodeNumbers.map((episode) => ({
content_id: showImdbId,
content_type: 'series' as const,
title: showImdbId,
title: showTitle || showImdbId,
season,
episode,
watched_at: watchedAt.getTime(),

View file

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