mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-28 05:23:01 +00:00
Merge branch 'NuvioMedia:main' into main
This commit is contained in:
commit
ca4edfc638
19 changed files with 91868 additions and 435 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
8
app.json
8
app.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue