feat: restore project state to commit 696a2379 and integrate new MappingService

This commit is contained in:
paregi12 2026-01-17 09:44:56 +05:30
parent a8867df4e6
commit 744f79a264
36 changed files with 136144 additions and 373 deletions

45
.github/workflows/android-debug.yml vendored Normal file
View file

@ -0,0 +1,45 @@
name: Build Android Debug
on:
push:
branches: [ "main" ]
workflow_dispatch:
jobs:
build:
name: Build Debug APK
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Grant execute permission for gradlew
run: chmod +x android/gradlew
- name: Build Debug APK
working-directory: android
# assembleDebug automatically uses a standard android debug key
run: ./gradlew :app:assembleDebug
- name: Upload Debug APK
uses: actions/upload-artifact@v4
with:
name: app-debug
path: android/app/build/outputs/apk/debug/*.apk

View file

@ -1,37 +1,60 @@
name: Release Build
name: Build Android Release
on:
push:
tags:
- 'v*'
branches:
- main
workflow_dispatch:
jobs:
release:
build:
name: Build Android Release (arm64-v8a)
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: 'gradle'
- name: Setup Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build app
run: npm ci --legacy-peer-deps
- name: Build Release APK
working-directory: android
env:
EXPO_PUBLIC_INTRODB_API_URL: "https://api.introdb.app"
EXPO_PUBLIC_SUPABASE_URL: "https://example.supabase.co"
EXPO_PUBLIC_SUPABASE_ANON_KEY: "dummy-key"
EXPO_PUBLIC_USE_REMOTE_CACHE: "false"
EXPO_PUBLIC_MOVIEBOX_PRIMARY_KEY: "dummy"
EXPO_PUBLIC_MOVIEBOX_TMDB_API_KEY: "dummy"
EXPO_PUBLIC_TRAKT_CLIENT_ID: "dummy"
EXPO_PUBLIC_TRAKT_CLIENT_SECRET: "dummy"
EXPO_PUBLIC_TRAKT_REDIRECT_URI: "stremioexpo://auth/trakt"
EXPO_PUBLIC_DISCORD_USER_API: "https://discord.com/api"
run: |
npm run build
chmod +x gradlew
./gradlew :app:assembleRelease -Pinclude=arm64-v8a
- name: Create Release
uses: softprops/action-gh-release@v1
- name: List Output Files
run: ls -R android/app/build/outputs/apk/
- name: Upload APK Artifact
uses: actions/upload-artifact@v4
with:
files: |
android/app/build/outputs/apk/release/*.apk
body_path: ALPHA_BUILD_2_ANNOUNCEMENT.md
draft: true
prerelease: true
generate_release_notes: true
name: app-release-arm64-v8a
path: android/app/build/outputs/apk/release/*.apk

View file

@ -44,6 +44,7 @@ import { AccountProvider, useAccount } from './src/contexts/AccountContext';
import { ToastProvider } from './src/contexts/ToastContext';
import { mmkvStorage } from './src/services/mmkvStorage';
import { CampaignManager } from './src/components/promotions/CampaignManager';
import { WebViewExtractor } from './src/components/WebViewExtractor';
Sentry.init({
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
@ -202,6 +203,7 @@ const ThemedApp = () => {
onDismiss={githubUpdate.onDismiss}
onLater={githubUpdate.onLater}
/>
<WebViewExtractor />
<CampaignManager />
</View>
</DownloadsProvider>

View file

@ -172,7 +172,7 @@ class MPVView @JvmOverloads constructor(
MPVLib.setOptionString("http-reconnect", "yes")
MPVLib.setOptionString("stream-reconnect", "yes")
MPVLib.setOptionString("demuxer-lavf-o", "live_start_index=0,prefer_x_start=1,http_persistent=0")
MPVLib.setOptionString("demuxer-lavf-o", "live_start_index=0,prefer_x_start=1,http_persistent=1")
MPVLib.setOptionString("demuxer-seekable-cache", "yes")
MPVLib.setOptionString("force-seekable", "yes")
@ -235,6 +235,10 @@ class MPVView @JvmOverloads constructor(
Log.d(TAG, "Loading file: $url")
// Reset load event flag for new file
hasLoadEventFired = false
// Re-apply headers before loading to ensure segments/keys use the correct headers
applyHttpHeadersAsOptions()
MPVLib.command(arrayOf("loadfile", url))
}
@ -252,25 +256,39 @@ class MPVView @JvmOverloads constructor(
fun setHeaders(headers: Map<String, String>?) {
httpHeaders = headers
Log.d(TAG, "Headers set: $headers")
if (isMpvInitialized) {
applyHttpHeadersAsOptions()
}
}
private fun applyHttpHeadersAsOptions() {
// Always set user-agent (this works reliably)
val userAgent = httpHeaders?.get("User-Agent")
// Find User-Agent (case-insensitive)
val userAgentKey = httpHeaders?.keys?.find { it.equals("User-Agent", ignoreCase = true) }
val userAgent = userAgentKey?.let { httpHeaders?.get(it) }
?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
Log.d(TAG, "Setting User-Agent: $userAgent")
MPVLib.setOptionString("user-agent", userAgent)
// Additionally, set other headers via http-header-fields if present
// This is needed for streams that require Referer, Origin, Cookie, etc.
if (isMpvInitialized) {
MPVLib.setPropertyString("user-agent", userAgent)
} else {
MPVLib.setOptionString("user-agent", userAgent)
}
httpHeaders?.let { headers ->
val otherHeaders = headers.filterKeys { it != "User-Agent" }
val otherHeaders = headers.filterKeys { !it.equals("User-Agent", ignoreCase = true) }
if (otherHeaders.isNotEmpty()) {
// Format as comma-separated "Key: Value" pairs
val headerString = otherHeaders.map { (key, value) -> "$key: $value" }.joinToString(",")
Log.d(TAG, "Setting additional headers: $headerString")
MPVLib.setOptionString("http-header-fields", headerString)
// Use newline separator for http-header-fields as it's the standard for mpv
val headerString = otherHeaders.map { (key, value) -> "$key: $value" }.joinToString("\n")
Log.d(TAG, "Setting additional headers:\n$headerString")
if (isMpvInitialized) {
MPVLib.setPropertyString("http-header-fields", headerString)
} else {
MPVLib.setOptionString("http-header-fields", headerString)
}
} else if (isMpvInitialized) {
MPVLib.setPropertyString("http-header-fields", "")
}
}
}

View file

@ -1,4 +1,11 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
maven { url 'https://plugins.gradle.org/m2/' }
}
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

15
package-lock.json generated
View file

@ -93,6 +93,7 @@
"react-native-vector-icons": "^10.3.0",
"react-native-video": "6.18.0",
"react-native-web": "^0.21.0",
"react-native-webview": "^13.15.0",
"react-native-wheel-color-picker": "^1.3.1",
"react-native-worklets": "^0.7.1"
},
@ -11236,6 +11237,20 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/react-native-webview": {
"version": "13.15.0",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz",
"integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^4.0.0",
"invariant": "2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-wheel-color-picker": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/react-native-wheel-color-picker/-/react-native-wheel-color-picker-1.3.1.tgz",

View file

@ -93,6 +93,7 @@
"react-native-vector-icons": "^10.3.0",
"react-native-video": "6.18.0",
"react-native-web": "^0.21.0",
"react-native-webview": "^13.15.0",
"react-native-wheel-color-picker": "^1.3.1",
"react-native-worklets": "^0.7.1"
},

132969
src/assets/mappings.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,148 @@
import React, { useEffect, useState, useRef } from 'react';
import { View, StyleSheet } from 'react-native';
import { WebView } from 'react-native-webview';
import { streamExtractorService, EXTRACTOR_EVENTS } from '../services/StreamExtractorService';
import { logger } from '../utils/logger';
export const WebViewExtractor: React.FC = () => {
const [currentRequest, setCurrentRequest] = useState<{ id: string; url: string; script?: string } | null>(null);
const webViewRef = useRef<WebView>(null);
useEffect(() => {
const startListener = (request: { id: string; url: string; script?: string }) => {
logger.log(`[WebViewExtractor] Received request: ${request.url}`);
setCurrentRequest(request);
};
streamExtractorService.events.on(EXTRACTOR_EVENTS.START_EXTRACTION, startListener);
return () => {
streamExtractorService.events.off(EXTRACTOR_EVENTS.START_EXTRACTION, startListener);
};
}, []);
const handleMessage = (event: any) => {
if (!currentRequest) return;
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'found_stream') {
streamExtractorService.events.emit(EXTRACTOR_EVENTS.EXTRACTION_SUCCESS, {
id: currentRequest.id,
streamUrl: data.url,
headers: data.headers
});
setCurrentRequest(null); // Reset after success
} else if (data.type === 'error') {
// Optional: Retry logic or just log
logger.warn(`[WebViewExtractor] Error from page: ${data.message}`);
}
} catch (e) {
logger.error('[WebViewExtractor] Failed to parse message:', e);
}
};
const handleError = (syntheticEvent: any) => {
const { nativeEvent } = syntheticEvent;
logger.warn('[WebViewExtractor] WebView error: ', nativeEvent);
if (currentRequest) {
streamExtractorService.events.emit(EXTRACTOR_EVENTS.EXTRACTION_FAILURE, {
id: currentRequest.id,
error: `WebView Error: ${nativeEvent.description}`
});
setCurrentRequest(null);
}
};
// Default extraction script: looks for video tags and intercepts network traffic
const DEFAULT_INJECTED_JS = `
(function() {
function sendStream(url, headers) {
// Broad regex to catch HLS, DASH, and common video containers
const videoRegex = /\.(m3u8|mp4|mpd|mkv|webm|mov|avi)(\?|$)/i;
if (!videoRegex.test(url)) return;
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'found_stream',
url: url,
headers: headers
}));
}
// 1. Intercept Video Elements
function checkVideoElements() {
var videos = document.getElementsByTagName('video');
for (var i = 0; i < videos.length; i++) {
if (videos[i].src && !videos[i].src.startsWith('blob:')) {
sendStream(videos[i].src);
return true;
}
// Check for source children
var sources = videos[i].getElementsByTagName('source');
for (var j = 0; j < sources.length; j++) {
if (sources[j].src) {
sendStream(sources[j].src);
return true;
}
}
}
return false;
}
// Check periodically
setInterval(checkVideoElements, 1000);
// 2. Intercept XHR (optional, for m3u8/mp4 fetches)
var originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
sendStream(url);
originalOpen.apply(this, arguments);
};
// 3. Intercept Fetch
var originalFetch = window.fetch;
window.fetch = function(input, init) {
var url = typeof input === 'string' ? input : (input instanceof Request ? input.url : '');
sendStream(url);
return originalFetch.apply(this, arguments);
};
// 4. Check for specific common player variables (optional)
// e.g., jwplayer, etc.
})();
`;
if (!currentRequest) {
return null;
}
return (
<View style={styles.hiddenContainer}>
<WebView
ref={webViewRef}
source={{ uri: currentRequest.url }}
onMessage={handleMessage}
onError={handleError}
injectedJavaScript={currentRequest.script || DEFAULT_INJECTED_JS}
javaScriptEnabled={true}
domStorageEnabled={true}
allowsInlineMediaPlayback={true}
mediaPlaybackRequiresUserAction={false}
// Important: Use a desktop or generic user agent to avoid mobile redirect loops sometimes
userAgent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
style={{ width: 1, height: 1 }} // Minimal size to keep it active
/>
</View>
);
};
const styles = StyleSheet.create({
hiddenContainer: {
position: 'absolute',
top: -1000, // Move off-screen
left: 0,
width: 1,
height: 1,
opacity: 0.01, // Almost invisible but technically rendered
},
});

View file

@ -105,6 +105,8 @@ interface HeroSectionProps {
dynamicBackgroundColor?: string;
handleBack: () => void;
tmdbId?: number | null;
malId?: number | null;
onMalPress?: () => void;
}
// Ultra-optimized ActionButtons Component - minimal re-renders
@ -127,7 +129,9 @@ const ActionButtons = memo(({
isInWatchlist,
isInCollection,
onToggleWatchlist,
onToggleCollection
onToggleCollection,
malId,
onMalPress
}: {
handleShowStreams: () => void;
toggleLibrary: () => void;
@ -148,6 +152,8 @@ const ActionButtons = memo(({
isInCollection?: boolean;
onToggleWatchlist?: () => void;
onToggleCollection?: () => void;
malId?: number | null;
onMalPress?: () => void;
}) => {
const { currentTheme } = useTheme();
const { t } = useTranslation();
@ -335,12 +341,13 @@ const ActionButtons = memo(({
return isWatched ? t('metadata.play') : playButtonText;
}, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]);
// Count additional buttons (excluding Play and Save) - AI Chat no longer counted
// Count additional buttons (AI Chat removed - now in top right corner)
const hasTraktCollection = isAuthenticated;
const hasRatings = type === 'series';
const hasMal = !!malId;
// Count additional buttons (AI Chat removed - now in top right corner)
const additionalButtonCount = (hasTraktCollection ? 1 : 0) + (hasRatings ? 1 : 0);
const additionalButtonCount = (hasTraktCollection ? 1 : 0) + (hasRatings ? 1 : 0) + (hasMal ? 1 : 0);
return (
<Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}>
@ -453,6 +460,33 @@ const ActionButtons = memo(({
/>
</TouchableOpacity>
)}
{/* MAL Button */}
{hasMal && (
<TouchableOpacity
style={[styles.iconButton, isTablet && styles.tabletIconButton, styles.singleRowIconButton]}
onPress={onMalPress}
activeOpacity={0.85}
>
{Platform.OS === 'ios' ? (
GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp
style={styles.blurBackgroundRound}
glassEffectStyle="regular"
/>
) : (
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
)
) : (
<View style={styles.androidFallbackBlurRound} />
)}
<Image
source={require('../../../assets/rating-icons/mal-icon.png')}
style={{ width: isTablet ? 28 : 24, height: isTablet ? 28 : 24, borderRadius: isTablet ? 14 : 12 }}
resizeMode="contain"
/>
</TouchableOpacity>
)}
</View>
</Animated.View>
);
@ -857,6 +891,8 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
dynamicBackgroundColor,
handleBack,
tmdbId,
malId,
onMalPress,
// Trakt integration props
isAuthenticated,
isInWatchlist,
@ -1898,6 +1934,8 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
isInCollection={isInCollection}
onToggleWatchlist={onToggleWatchlist}
onToggleCollection={onToggleCollection}
malId={malId}
onMalPress={onMalPress}
/>
</View>
</LinearGradient>

View file

@ -0,0 +1,260 @@
import React, { useState, useEffect, useRef } from 'react';
import { Modal, View, Text, StyleSheet, TouchableOpacity, ScrollView, ActivityIndicator, Image } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { MalApiService } from '../../services/mal/MalApi';
import { MalSync } from '../../services/mal/MalSync';
import { MalListStatus } from '../../types/mal';
import { useTheme } from '../../contexts/ThemeContext';
interface MalScoreModalProps {
visible: boolean;
onClose: () => void;
malId: number;
animeTitle: string;
initialStatus?: MalListStatus;
initialScore?: number;
initialEpisodes?: number;
// Season support props
seasons?: number[];
currentSeason?: number;
imdbId?: string;
type?: 'movie' | 'series';
}
const STATUS_OPTIONS: { label: string; value: MalListStatus }[] = [
{ label: 'Watching', value: 'watching' },
{ label: 'Completed', value: 'completed' },
{ label: 'On Hold', value: 'on_hold' },
{ label: 'Dropped', value: 'dropped' },
{ label: 'Plan to Watch', value: 'plan_to_watch' },
];
export const MalScoreModal: React.FC<MalScoreModalProps> = ({
visible,
onClose,
malId,
animeTitle,
initialStatus = 'watching',
initialScore = 0,
initialEpisodes = 0,
seasons = [],
currentSeason = 1,
imdbId,
type = 'series'
}) => {
const { currentTheme } = useTheme();
// State for season management
const [selectedSeason, setSelectedSeason] = useState(currentSeason);
const [activeMalId, setActiveMalId] = useState(malId);
const [fetchingData, setFetchingData] = useState(false);
// Form State
const [status, setStatus] = useState<MalListStatus>(initialStatus);
const [score, setScore] = useState(initialScore);
const [episodes, setEpisodes] = useState(initialEpisodes);
const [totalEpisodes, setTotalEpisodes] = useState(0);
const [loading, setLoading] = useState(false);
// Fetch data when season changes (only for series with multiple seasons)
useEffect(() => {
const loadSeasonData = async () => {
setFetchingData(true);
// Reset active ID to prevent writing to the wrong season if fetch fails
setActiveMalId(0);
try {
// 1. Resolve MAL ID for this season
let resolvedId = malId;
if (type === 'series' && (seasons.length > 1 || selectedSeason !== currentSeason)) {
resolvedId = await MalSync.getMalId(animeTitle, type, undefined, selectedSeason, imdbId) || 0;
}
if (resolvedId) {
setActiveMalId(resolvedId);
// 2. Fetch user status for this ID
const data = await MalApiService.getMyListStatus(resolvedId);
if (data.list_status) {
setStatus(data.list_status.status);
setScore(data.list_status.score);
setEpisodes(data.list_status.num_episodes_watched);
} else {
// Default if not in list
setStatus('plan_to_watch');
setScore(0);
setEpisodes(0);
}
setTotalEpisodes(data.num_episodes || 0);
} else {
console.warn('Could not resolve MAL ID for season', selectedSeason);
}
} catch (e) {
console.error('Failed to load season data', e);
} finally {
setFetchingData(false);
}
};
loadSeasonData();
}, [selectedSeason, type, animeTitle, imdbId, malId]);
const handleSave = async () => {
setLoading(true);
try {
await MalApiService.updateStatus(activeMalId, status, episodes, score);
onClose();
} catch (e) {
console.error('Failed to update MAL status', e);
} finally {
setLoading(false);
}
};
return (
<Modal visible={visible} transparent animationType="fade">
<View style={styles.overlay}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.elevation2 || '#1E1E1E' }]}>
<View style={styles.header}>
<Image
source={require('../../../assets/rating-icons/mal-icon.png')}
style={styles.logo}
resizeMode="contain"
/>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>{animeTitle}</Text>
</View>
{/* Season Selector */}
{type === 'series' && seasons.length > 1 && (
<View style={styles.seasonContainer}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis, marginTop: 0 }]}>Season</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.seasonScroll}>
{seasons.sort((a, b) => a - b).map((s) => (
<TouchableOpacity
key={s}
style={[
styles.seasonChip,
selectedSeason === s && { backgroundColor: currentTheme.colors.primary },
{ borderColor: currentTheme.colors.border }
]}
onPress={() => setSelectedSeason(s)}
>
<Text style={[styles.chipText, selectedSeason === s && { color: '#fff', fontWeight: 'bold' }]}>
Season {s}
</Text>
</TouchableOpacity>
))}
</ScrollView>
</View>
)}
{fetchingData ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
</View>
) : (
<ScrollView>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis }]}>Status</Text>
<View style={styles.optionsRow}>
{STATUS_OPTIONS.map((opt) => (
<TouchableOpacity
key={opt.value}
style={[
styles.chip,
status === opt.value && { backgroundColor: currentTheme.colors.primary },
{ borderColor: currentTheme.colors.border }
]}
onPress={() => setStatus(opt.value)}
>
<Text style={[styles.chipText, status === opt.value && { color: '#fff' }]}>{opt.label}</Text>
</TouchableOpacity>
))}
</View>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis }]}>Episodes Watched</Text>
<View style={styles.episodeRow}>
<TouchableOpacity
style={[styles.roundButton, { borderColor: currentTheme.colors.border }]}
onPress={() => setEpisodes(Math.max(0, episodes - 1))}
>
<MaterialIcons name="remove" size={20} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
<View style={styles.episodeDisplay}>
<Text style={[styles.episodeCount, { color: currentTheme.colors.highEmphasis }]}>{episodes}</Text>
{totalEpisodes > 0 && (
<Text style={[styles.totalEpisodes, { color: currentTheme.colors.mediumEmphasis }]}> / {totalEpisodes}</Text>
)}
</View>
<TouchableOpacity
style={[styles.roundButton, { borderColor: currentTheme.colors.border }]}
onPress={() => setEpisodes(totalEpisodes > 0 ? Math.min(totalEpisodes, episodes + 1) : episodes + 1)}
>
<MaterialIcons name="add" size={20} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
</View>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis }]}>Score</Text>
<View style={styles.optionsRow}>
{[...Array(11).keys()].map((s) => (
<TouchableOpacity
key={s}
style={[
styles.scoreChip,
score === s && { backgroundColor: '#F5C518', borderColor: '#F5C518' },
{ borderColor: currentTheme.colors.border }
]}
onPress={() => setScore(s)}
>
<Text style={[styles.chipText, score === s && { color: '#000', fontWeight: 'bold' }]}>{s === 0 ? '-' : s}</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
)}
<View style={styles.footer}>
<TouchableOpacity style={styles.cancelButton} onPress={onClose}>
<Text style={{ color: currentTheme.colors.mediumEmphasis }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.saveButton, { backgroundColor: currentTheme.colors.primary, opacity: (loading || fetchingData || !activeMalId) ? 0.6 : 1 }]}
onPress={handleSave}
disabled={loading || fetchingData || !activeMalId}
>
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.saveButtonText}>Save</Text>}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.8)', justifyContent: 'center', padding: 20 },
container: { borderRadius: 16, padding: 20, maxHeight: '85%' },
header: { flexDirection: 'row', alignItems: 'center', marginBottom: 20 },
logo: { width: 32, height: 32, marginRight: 12, borderRadius: 8 },
title: { fontSize: 18, fontWeight: 'bold', flex: 1 },
sectionTitle: { fontSize: 14, fontWeight: '600', marginTop: 16, marginBottom: 8 },
optionsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, borderWidth: 1, marginBottom: 4 },
scoreChip: { width: 40, height: 40, borderRadius: 20, borderWidth: 1, justifyContent: 'center', alignItems: 'center', marginBottom: 4 },
chipText: { fontSize: 12, fontWeight: '500' },
footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: 16, marginTop: 24 },
cancelButton: { padding: 12 },
saveButton: { paddingHorizontal: 24, paddingVertical: 12, borderRadius: 24, minWidth: 100, alignItems: 'center' },
saveButtonText: { color: '#fff', fontWeight: 'bold' },
seasonContainer: { marginBottom: 8 },
seasonScroll: { paddingVertical: 4, gap: 8 },
seasonChip: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, borderWidth: 1, marginRight: 8 },
loadingContainer: { height: 200, justifyContent: 'center', alignItems: 'center' },
episodeRow: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 8 },
roundButton: { width: 40, height: 40, borderRadius: 20, borderWidth: 1, justifyContent: 'center', alignItems: 'center' },
episodeDisplay: { flexDirection: 'row', alignItems: 'baseline', minWidth: 80, justifyContent: 'center' },
episodeCount: { fontSize: 20, fontWeight: 'bold' },
totalEpisodes: { fontSize: 14, marginLeft: 2 },
});

View file

@ -17,8 +17,9 @@ import { TraktService } from '../../services/traktService';
import { watchedService } from '../../services/watchedService';
import { logger } from '../../utils/logger';
import { mmkvStorage } from '../../services/mmkvStorage';
import { MalSync } from '../../services/mal/MalSync';
// Enhanced responsive breakpoints for Seasons Section
// ... other imports
const BREAKPOINTS = {
phone: 0,
tablet: 768,
@ -33,7 +34,7 @@ interface SeriesContentProps {
onSeasonChange: (season: number) => void;
onSelectEpisode: (episode: Episode) => void;
groupedEpisodes?: { [seasonNumber: number]: Episode[] };
metadata?: { poster?: string; id?: string };
metadata?: { poster?: string; id?: string; name?: string };
imdbId?: string; // IMDb ID for Trakt sync
}
@ -580,6 +581,13 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
episode.episode_number
);
// Sync to MAL
const malEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true;
if (malEnabled && metadata?.name) {
const totalEpisodes = Object.values(groupedEpisodes).reduce((acc, curr) => acc + (curr?.length || 0), 0);
MalSync.scrobbleEpisode(metadata.name, episode.episode_number, totalEpisodes, 'series', episode.season_number, imdbId);
}
// Reload to ensure consistency (e.g. if optimistic update was slightly off or for other effects)
// But we don't strictly *need* to wait for this to update UI
loadEpisodesProgress();
@ -663,6 +671,14 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
episodeNumbers
);
// Sync to MAL (last episode of the season)
const malEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true;
if (malEnabled && metadata?.name && episodeNumbers.length > 0) {
const lastEp = Math.max(...episodeNumbers);
const totalEpisodes = Object.values(groupedEpisodes).reduce((acc, curr) => acc + (curr?.length || 0), 0);
MalSync.scrobbleEpisode(metadata.name, lastEp, totalEpisodes, 'series', currentSeason, imdbId);
}
// Re-sync with source of truth
loadEpisodesProgress();

View file

@ -1,5 +1,5 @@
import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
import { View, StyleSheet, Platform, Animated, ToastAndroid } from 'react-native';
import { View, StyleSheet, Platform, Animated, ToastAndroid, ActivityIndicator, Text } from 'react-native';
import { toast } from '@backpackapp-io/react-native-toast';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
@ -53,13 +53,18 @@ import { MpvPlayerRef } from './android/MpvPlayer';
// Utils
import { logger } from '../../utils/logger';
import { styles } from './utils/playerStyles';
import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT } from './utils/playerUtils';
import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSubtitle } from './utils/playerUtils';
import { storageService } from '../../services/storageService';
import stremioService from '../../services/stremioService';
import { localScraperService } from '../../services/pluginService';
import { TMDBService } from '../../services/tmdbService';
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
import { useTheme } from '../../contexts/ThemeContext';
import axios from 'axios';
import { streamExtractorService } from '../../services/StreamExtractorService';
import { MalSync } from '../../services/mal/MalSync';
import { mmkvStorage } from '../../services/mmkvStorage';
const DEBUG_MODE = false;
@ -90,6 +95,56 @@ const AndroidVideoPlayer: React.FC = () => {
const [currentStreamUrl, setCurrentStreamUrl] = useState<string>(uri);
const [currentVideoType, setCurrentVideoType] = useState<string | undefined>((route.params as any).videoType);
// Stream Resolution State
const [isResolving, setIsResolving] = useState(false);
const [resolutionError, setResolutionError] = useState<string | null>(null);
// Auto-resolve stream URL if it's an embed
useEffect(() => {
const resolveStream = async () => {
// Simple heuristic: If it doesn't look like a direct video file, try to resolve it
// Valid video extensions: .mp4, .mkv, .avi, .m3u8, .mpd, .mov, .flv, .webm
// Also check for magnet links (which don't need resolution)
const lowerUrl = uri.toLowerCase();
const isDirectFile = /\.(mp4|mkv|avi|m3u8|mpd|mov|flv|webm)(\?|$)/.test(lowerUrl);
const isMagnet = lowerUrl.startsWith('magnet:');
// If it looks like a direct link or magnet, skip resolution
if (isDirectFile || isMagnet) {
setCurrentStreamUrl(uri);
return;
}
logger.log(`[AndroidVideoPlayer] URL ${uri} does not look like a direct file. Attempting resolution...`);
setIsResolving(true);
setResolutionError(null);
try {
const result = await streamExtractorService.extractStream(uri);
if (result && result.streamUrl) {
logger.log(`[AndroidVideoPlayer] Resolved stream: ${result.streamUrl}`);
setCurrentStreamUrl(result.streamUrl);
// If headers were returned, we might want to use them (though current player prop structure is simple)
// For now we just use the URL
} else {
logger.warn(`[AndroidVideoPlayer] Resolution returned no URL, using original.`);
setCurrentStreamUrl(uri); // Fallback to original
}
} catch (error) {
logger.error(`[AndroidVideoPlayer] Stream resolution failed:`, error);
// Fallback to original URL on error, maybe it works anyway?
setCurrentStreamUrl(uri);
// Optional: setResolutionError(error.message);
} finally {
setIsResolving(false);
}
};
resolveStream();
}, [uri]);
const [availableStreams, setAvailableStreams] = useState<any>(passedAvailableStreams || {});
const [currentQuality, setCurrentQuality] = useState(quality);
@ -201,6 +256,42 @@ const AndroidVideoPlayer: React.FC = () => {
episodeId: episodeId
});
// MAL Auto Tracking
const malTrackingRef = useRef(false);
useEffect(() => {
malTrackingRef.current = false;
}, [id, season, episode]);
useEffect(() => {
if (playerState.duration > 0 && playerState.currentTime > 0) {
const progress = playerState.currentTime / playerState.duration;
if (progress > 0.85 && !malTrackingRef.current) {
const autoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true;
const malEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true;
// Strict Mode: Only sync if source is explicitly MAL/Kitsu (Prevents Cinemeta mismatched syncing)
const isAnimeSource = id && (id.startsWith('mal:') || id.startsWith('kitsu:') || id.includes(':mal:') || id.includes(':kitsu:'));
if (malEnabled && autoUpdate && title && isAnimeSource) {
malTrackingRef.current = true;
// Calculate total episodes for completion status
let totalEpisodes = 0;
if (type === 'series' && groupedEpisodes) {
totalEpisodes = Object.values(groupedEpisodes).reduce((acc, curr) => acc + (Array.isArray(curr) ? curr.length : 0), 0);
}
// If series, use episode number. If movie, use 1.
const epNum = type === 'series' ? episode : 1;
if (epNum) {
MalSync.scrobbleEpisode(title, epNum, totalEpisodes, type as any, season, imdbId || undefined);
}
}
}
}
}, [playerState.currentTime, playerState.duration, title, episode]);
const watchProgress = useWatchProgress(
id, type, episodeId,
playerState.currentTime,
@ -617,41 +708,85 @@ const AndroidVideoPlayer: React.FC = () => {
// Subtitle addon fetching
const fetchAvailableSubtitles = useCallback(async () => {
const targetImdbId = imdbId;
if (!targetImdbId) {
logger.warn('[AndroidVideoPlayer] No IMDB ID for subtitle fetch');
return;
}
setIsLoadingSubtitleList(true);
try {
const stremioType = type === 'series' ? 'series' : 'movie';
const stremioVideoId = stremioType === 'series' && season && episode
? `series:${targetImdbId}:${season}:${episode}`
: undefined;
const results = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId);
const subs: WyzieSubtitle[] = (results || []).map((sub: any) => ({
id: sub.id || `${sub.lang}-${sub.url}`,
url: sub.url,
flagUrl: '',
format: 'srt',
encoding: 'utf-8',
media: sub.addonName || sub.addon || '',
display: sub.lang || 'Unknown',
language: (sub.lang || '').toLowerCase(),
isHearingImpaired: false,
source: sub.addonName || sub.addon || 'Addon',
}));
// 1. Fetch from Stremio addons
const stremioPromise = stremioService.getSubtitles(stremioType, targetImdbId || '', stremioVideoId)
.then(results => (results || []).map((sub: any) => ({
id: sub.id || `${sub.lang}-${sub.url}`,
url: sub.url,
flagUrl: '',
format: 'srt',
encoding: 'utf-8',
media: sub.addonName || sub.addon || '',
display: sub.lang || 'Unknown',
language: (sub.lang || '').toLowerCase(),
isHearingImpaired: false,
source: sub.addonName || sub.addon || 'Addon',
})))
.catch(e => {
logger.error('[AndroidVideoPlayer] Error fetching Stremio subtitles', e);
return [];
});
setAvailableSubtitles(subs);
logger.info(`[AndroidVideoPlayer] Fetched ${subs.length} addon subtitles`);
// Auto-selection is now handled by useEffect that waits for internal tracks
// 2. Fetch from Local Plugins
const pluginPromise = (async () => {
try {
let tmdbIdStr: string | null = null;
// Try to resolve TMDB ID
if (id && id.startsWith('tmdb:')) {
tmdbIdStr = id.split(':')[1];
} else if (targetImdbId) {
const resolvedId = await TMDBService.getInstance().findTMDBIdByIMDB(targetImdbId);
if (resolvedId) tmdbIdStr = resolvedId.toString();
}
if (tmdbIdStr) {
const results = await localScraperService.getSubtitles(
stremioType === 'series' ? 'tv' : 'movie',
tmdbIdStr,
season,
episode
);
return results.map((sub: any) => ({
id: sub.url, // Use URL as ID for simple deduplication
url: sub.url,
flagUrl: '',
format: sub.format || 'srt',
encoding: 'utf-8',
media: sub.label || sub.addonName || 'Plugin',
display: sub.label || sub.lang || 'Plugin',
language: (sub.lang || 'en').toLowerCase(),
isHearingImpaired: false,
source: sub.addonName || 'Plugin'
}));
}
} catch (e) {
logger.warn('[AndroidVideoPlayer] Error fetching plugin subtitles', e);
}
return [];
})();
const [stremioSubs, pluginSubs] = await Promise.all([stremioPromise, pluginPromise]);
const allSubs = [...pluginSubs, ...stremioSubs];
setAvailableSubtitles(allSubs);
logger.info(`[AndroidVideoPlayer] Fetched ${allSubs.length} subtitles (${stremioSubs.length} Stremio, ${pluginSubs.length} Plugins)`);
} catch (e) {
logger.error('[AndroidVideoPlayer] Error fetching addon subtitles', e);
logger.error('[AndroidVideoPlayer] Error in fetchAvailableSubtitles', e);
} finally {
setIsLoadingSubtitleList(false);
}
}, [imdbId, type, season, episode]);
}, [imdbId, type, season, episode, id]);
const loadWyzieSubtitle = useCallback(async (subtitle: WyzieSubtitle) => {
if (!subtitle.url) return;
@ -670,7 +805,7 @@ const AndroidVideoPlayer: React.FC = () => {
}
// Parse subtitle file
const parsedCues = parseSRT(srtContent);
const parsedCues = parseSubtitle(srtContent, subtitle.url);
setCustomSubtitles(parsedCues);
setUseCustomSubtitles(true);
setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle
@ -733,8 +868,40 @@ const AndroidVideoPlayer: React.FC = () => {
height={playerState.screenDimensions.height}
/>
{/* Stream Resolution Overlay */}
{isResolving && (
<View style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'black',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999
}}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={{
color: 'white',
marginTop: 20,
fontSize: 16,
fontWeight: '600'
}}>
Resolving Stream Source...
</Text>
<Text style={{
color: 'rgba(255,255,255,0.7)',
marginTop: 8,
fontSize: 14
}}>
Extracting video from embed link
</Text>
</View>
)}
<View style={{ flex: 1, backgroundColor: 'black' }}>
{!isTransitioningStream && (
{!isTransitioningStream && !isResolving && (
<VideoSurface
processedStreamUrl={currentStreamUrl}
headers={headers}

View file

@ -50,8 +50,10 @@ import { logger } from '../../utils/logger';
// Utils
import { formatTime } from './utils/playerUtils';
import { localScraperService } from '../../services/pluginService';
import { TMDBService } from '../../services/tmdbService';
import { WyzieSubtitle } from './utils/playerTypes';
import { parseSRT } from './utils/subtitleParser';
import { parseSubtitle } from './utils/subtitleParser';
import { findBestSubtitleTrack, autoSelectAudioTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
import { useSettings } from '../../hooks/useSettings';
import { useTheme } from '../../contexts/ThemeContext';
@ -276,32 +278,80 @@ const KSPlayerCore: React.FC = () => {
// Subtitle Fetching Logic
const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = true) => {
const targetImdbId = imdbIdParam || imdbId;
if (!targetImdbId) return;
customSubs.setIsLoadingSubtitleList(true);
try {
const stremioType = type === 'series' ? 'series' : 'movie';
const stremioVideoId = stremioType === 'series' && season && episode ? `series:${targetImdbId}:${season}:${episode}` : undefined;
const results = await stremioService.getSubtitles(stremioType, targetImdbId, stremioVideoId);
const stremioVideoId = stremioType === 'series' && season && episode
? `series:${targetImdbId}:${season}:${episode}`
: undefined;
const subs: WyzieSubtitle[] = (results || []).map((sub: any) => ({
id: sub.id || `${sub.lang}-${sub.url}`,
url: sub.url,
flagUrl: '',
format: 'srt',
encoding: 'utf-8',
media: sub.addonName || sub.addon || '',
display: sub.lang || 'Unknown',
language: (sub.lang || '').toLowerCase(),
isHearingImpaired: false,
source: sub.addonName || sub.addon || 'Addon',
}));
// 1. Fetch from Stremio addons
const stremioPromise = stremioService.getSubtitles(stremioType, targetImdbId || '', stremioVideoId)
.then(results => (results || []).map((sub: any) => ({
id: sub.id || `${sub.lang}-${sub.url}`,
url: sub.url,
flagUrl: '',
format: 'srt',
encoding: 'utf-8',
media: sub.addonName || sub.addon || '',
display: sub.lang || 'Unknown',
language: (sub.lang || '').toLowerCase(),
isHearingImpaired: false,
source: sub.addonName || sub.addon || 'Addon',
})))
.catch(e => {
logger.error('[KSPlayerCore] Error fetching Stremio subtitles', e);
return [];
});
customSubs.setAvailableSubtitles(subs);
// Auto-selection is now handled by useEffect that waits for internal tracks
// This ensures internal tracks are considered before falling back to external
} catch (e) {
logger.error('[VideoPlayer] Error fetching subtitles', e);
// 2. Fetch from Local Plugins
const pluginPromise = (async () => {
try {
let tmdbIdStr: string | null = null;
if (id && id.startsWith('tmdb:')) {
tmdbIdStr = id.split(':')[1];
} else if (targetImdbId) {
const resolvedId = await TMDBService.getInstance().findTMDBIdByIMDB(targetImdbId);
if (resolvedId) tmdbIdStr = resolvedId.toString();
}
if (tmdbIdStr) {
const results = await localScraperService.getSubtitles(
stremioType === 'series' ? 'tv' : 'movie',
tmdbIdStr,
season,
episode
);
return results.map((sub: any) => ({
id: sub.url,
url: sub.url,
flagUrl: '',
format: sub.format || 'srt',
encoding: 'utf-8',
media: sub.label || sub.addonName || 'Plugin',
display: sub.label || sub.lang || 'Plugin',
language: (sub.lang || 'en').toLowerCase(),
isHearingImpaired: false,
source: sub.addonName || 'Plugin'
}));
}
} catch (e) {
logger.warn('[KSPlayerCore] Error fetching plugin subtitles', e);
}
return [];
})();
const [stremioSubs, pluginSubs] = await Promise.all([stremioPromise, pluginPromise]);
const allSubs = [...pluginSubs, ...stremioSubs];
customSubs.setAvailableSubtitles(allSubs);
logger.info(`[KSPlayerCore] Fetched ${allSubs.length} subtitles (${stremioSubs.length} Stremio, ${pluginSubs.length} Plugins)`);
} catch (error) {
logger.error('[KSPlayerCore] Error in fetchAvailableSubtitles', error);
} finally {
customSubs.setIsLoadingSubtitleList(false);
}
@ -319,7 +369,8 @@ const KSPlayerCore: React.FC = () => {
const resp = await fetch(subtitle.url);
srtContent = await resp.text();
}
const parsedCues = parseSRT(srtContent);
// Parse subtitle file
const parsedCues = parseSubtitle(srtContent, subtitle.url);
customSubs.setCustomSubtitles(parsedCues);
customSubs.setUseCustomSubtitles(true);
customSubs.setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle

View file

@ -173,6 +173,9 @@ export const parseSRT = (srtContent: string): SubtitleCue[] => {
return parseSRTEnhanced(srtContent);
};
// Export universal subtitle parser
export { parseSubtitle };
/**
* Detect if text contains primarily RTL (right-to-left) characters
* Checks for Arabic, Hebrew, Persian, Urdu, and other RTL scripts

View file

@ -13,6 +13,7 @@ import { mmkvStorage } from '../services/mmkvStorage';
import { Stream } from '../types/metadata';
import { storageService } from '../services/storageService';
import { useSettings } from './useSettings';
import { MalSync } from '../services/mal/MalSync';
// Constants for timeouts and retries
const API_TIMEOUT = 10000; // 10 seconds
@ -534,6 +535,18 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Handle TMDB-specific IDs
let actualId = id;
// Handle MAL IDs
if (id.startsWith('mal:')) {
// STRICT MODE: Do NOT convert to IMDb/Cinemeta.
// We want to force the app to use AnimeKitsu (or other MAL-compatible addons) for metadata.
// This ensures we get correct Season/Episode mapping (Separate entries) instead of Cinemeta's "S1E26" mess.
console.log('🔍 [useMetadata] Keeping MAL ID for metadata fetch:', id);
// Note: Stream fetching (stremioService) WILL still convert this to IMDb secretly
// to ensure Torrentio works, but the Metadata UI will stay purely MAL-based.
}
if (id.startsWith('tmdb:')) {
// Always try the original TMDB ID first - let addons decide if they support it
console.log('🔍 [useMetadata] TMDB ID detected, trying original ID first:', { originalId: id });
@ -823,9 +836,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Store addon logo before TMDB enrichment overwrites it
const addonLogo = (finalMetadata as any).logo;
const addonName = finalMetadata.name;
const addonDescription = finalMetadata.description;
const addonBanner = finalMetadata.banner;
try {
@ -860,8 +870,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
finalMetadata = {
...finalMetadata,
name: (addonName && addonName.trim()) ? addonName : (localized.title || finalMetadata.name),
description: (addonDescription && addonDescription.trim()) ? addonDescription : (localized.overview || finalMetadata.description),
name: localized.title || finalMetadata.name,
description: localized.overview || finalMetadata.description,
movieDetails: movieDetailsObj,
...(productionInfo.length > 0 && { networks: productionInfo }),
};
@ -897,8 +907,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
finalMetadata = {
...finalMetadata,
name: (addonName && addonName.trim()) ? addonName : (localized.name || finalMetadata.name),
description: (addonDescription && addonDescription.trim()) ? addonDescription : (localized.overview || finalMetadata.description),
name: localized.name || finalMetadata.name,
description: localized.overview || finalMetadata.description,
tvDetails,
...(productionInfo.length > 0 && { networks: productionInfo }),
};
@ -930,7 +940,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
if (tmdbIdForLogo) {
const logoUrl = await tmdbService.getContentLogo(contentType, tmdbIdForLogo, preferredLanguage);
// Use TMDB logo if found, otherwise fall back to addon logo
finalMetadata.logo = addonLogo || logoUrl || undefined;
finalMetadata.logo = logoUrl || addonLogo || undefined;
if (__DEV__) {
console.log('[useMetadata] Logo fetch result:', {
contentType,
@ -970,15 +980,12 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
// Clear banner field if TMDB banner enrichment is enabled to prevent flash
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichBanners) {
if (!addonBanner && !finalMetadata.banner) {
finalMetadata = {
...finalMetadata,
banner: undefined,
// Let useMetadataAssets handle banner via TMDB
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichBanners && !finalMetadata.banner) {
finalMetadata = {
...finalMetadata,
banner: undefined, // Let useMetadataAssets handle banner via TMDB
};
}
}
// Preserve existing collection if it was set by fetchProductionInfo
setMetadata((prev) => {
@ -1543,9 +1550,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const allStremioAddons = await stremioService.getInstalledAddons();
const localScrapers = await localScraperService.getInstalledScrapers();
// Map app-level "tv" type to Stremio "series" for addon capability checks
const stremioType = type === 'tv' ? 'series' : type;
// Filter Stremio addons to only include those that provide streams for this content type
const streamAddons = allStremioAddons.filter(addon => {
if (!addon.resources || !Array.isArray(addon.resources)) {
@ -1561,7 +1565,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const typedResource = resource as any;
if (typedResource.name === 'stream' &&
Array.isArray(typedResource.types) &&
typedResource.types.includes(stremioType)) {
typedResource.types.includes(type)) {
hasStreamResource = true;
// Check if this addon supports the ID prefix generically: any prefix must match start of id
@ -1576,7 +1580,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
}
// Check if the element is the simple string "stream" AND the addon has a top-level types array
else if (typeof resource === 'string' && resource === 'stream' && addon.types) {
if (Array.isArray(addon.types) && addon.types.includes(stremioType)) {
if (Array.isArray(addon.types) && addon.types.includes(type)) {
hasStreamResource = true;
// For simple string resources, check addon-level idPrefixes generically
if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) {
@ -1644,9 +1648,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Start Stremio request using the converted ID format
if (__DEV__) console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId);
// Map app-level "tv" type to Stremio "series" when requesting streams
const stremioContentType = type === 'tv' ? 'series' : type;
processStremioSource(stremioContentType, stremioId, false);
processStremioSource(type, stremioId, false);
// Also extract any embedded streams from metadata (PPV-style addons)
extractEmbeddedStreams();
@ -1924,8 +1926,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// For collections, treat episodes as individual movies, not series
// For other types (e.g. StreamsPPV), preserve the original type unless it's explicitly 'series' logic we want
// Map app-level "tv" type to Stremio "series" for addon stream endpoint
const contentType = isCollection ? 'movie' : (type === 'tv' ? 'series' : type);
const contentType = isCollection ? 'movie' : type;
if (__DEV__) console.log(`🎬 [loadEpisodeStreams] Using content type: ${contentType} for ${isCollection ? 'collection' : type}`);
processStremioSource(contentType, stremioEpisodeId, true);

View file

@ -55,6 +55,7 @@ import TMDBSettingsScreen from '../screens/TMDBSettingsScreen';
import HomeScreenSettings from '../screens/HomeScreenSettings';
import HeroCatalogsScreen from '../screens/HeroCatalogsScreen';
import TraktSettingsScreen from '../screens/TraktSettingsScreen';
import MalSettingsScreen from '../screens/MalSettingsScreen';
import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
import ThemeScreen from '../screens/ThemeScreen';
import OnboardingScreen from '../screens/OnboardingScreen';
@ -185,6 +186,7 @@ export type RootStackParamList = {
HomeScreenSettings: undefined;
HeroCatalogs: undefined;
TraktSettings: undefined;
MalSettings: undefined;
PlayerSettings: undefined;
ThemeSettings: undefined;
ScraperSettings: undefined;
@ -1565,6 +1567,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
},
}}
/>
<Stack.Screen
name="MalSettings"
component={MalSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'default' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="PlayerSettings"
component={PlayerSettingsScreen}

View file

@ -31,6 +31,8 @@ import { tmdbService } from '../services/tmdbService';
import { logger } from '../utils/logger';
import { memoryManager } from '../utils/memoryManager';
import { useCalendarData } from '../hooks/useCalendarData';
import { AniListService } from '../services/anilist/AniListService';
import { AniListAiringSchedule } from '../services/anilist/types';
const { width } = Dimensions.get('window');
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -41,13 +43,17 @@ interface CalendarEpisode {
title: string;
seriesName: string;
poster: string;
releaseDate: string;
releaseDate: string | null;
season: number;
episode: number;
overview: string;
vote_average: number;
still_path: string | null;
season_poster_path: string | null;
// MAL specific
day?: string;
time?: string;
genres?: string[];
}
interface CalendarSection {
@ -75,14 +81,90 @@ const CalendarScreen = () => {
const [uiReady, setUiReady] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [filteredEpisodes, setFilteredEpisodes] = useState<CalendarEpisode[]>([]);
// AniList Integration
const [calendarSource, setCalendarSource] = useState<'nuvio' | 'anilist'>('nuvio');
const [aniListSchedule, setAniListSchedule] = useState<CalendarSection[]>([]);
const [aniListLoading, setAniListLoading] = useState(false);
const fetchAniListSchedule = useCallback(async () => {
setAniListLoading(true);
try {
const schedule = await AniListService.getWeeklySchedule();
// Group by Day
const grouped: Record<string, CalendarEpisode[]> = {};
const daysOrder = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
schedule.forEach((item) => {
const date = new Date(item.airingAt * 1000);
const dayName = format(date, 'EEEE'); // Monday, Tuesday...
if (!grouped[dayName]) {
grouped[dayName] = [];
}
const episode: CalendarEpisode = {
id: `kitsu:${item.media.idMal}`, // Fallback ID for now, ideally convert to IMDb/TMDB if possible
seriesId: `mal:${item.media.idMal}`, // Use MAL ID for series navigation
title: item.media.title.english || item.media.title.romaji, // Episode title not available, use series title
seriesName: item.media.title.english || item.media.title.romaji,
poster: item.media.coverImage.large || item.media.coverImage.medium,
releaseDate: new Date(item.airingAt * 1000).toISOString(),
season: 1, // AniList doesn't always provide season number easily
episode: item.episode,
overview: `Airing at ${format(date, 'HH:mm')}`,
vote_average: 0,
still_path: null,
season_poster_path: null,
day: dayName,
time: format(date, 'HH:mm'),
genres: [item.media.format] // Use format as genre for now
};
grouped[dayName].push(episode);
});
// Sort sections starting from today
const todayIndex = new Date().getDay(); // 0 = Sunday
const sortedSections: CalendarSection[] = [];
for (let i = 0; i < 7; i++) {
const dayIndex = (todayIndex + i) % 7;
const dayName = daysOrder[dayIndex];
if (grouped[dayName] && grouped[dayName].length > 0) {
sortedSections.push({
title: i === 0 ? 'Today' : (i === 1 ? 'Tomorrow' : dayName),
data: grouped[dayName].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
});
}
}
setAniListSchedule(sortedSections);
} catch (e) {
logger.error('Failed to load AniList schedule', e);
} finally {
setAniListLoading(false);
}
}, []);
useEffect(() => {
if (calendarSource === 'anilist' && aniListSchedule.length === 0) {
fetchAniListSchedule();
}
}, [calendarSource]);
const onRefresh = useCallback(() => {
setRefreshing(true);
// Check memory pressure before refresh
memoryManager.checkMemoryPressure();
refresh(true);
if (calendarSource === 'nuvio') {
refresh(true);
} else {
fetchAniListSchedule();
}
setRefreshing(false);
}, [refresh]);
}, [refresh, calendarSource, fetchAniListSchedule]);
// Defer heavy UI work until after interactions to reduce jank/crashes
useEffect(() => {
@ -115,20 +197,22 @@ const CalendarScreen = () => {
episodeId
});
}, [navigation, handleSeriesPress]);
const renderEpisodeItem = ({ item }: { item: CalendarEpisode }) => {
const hasReleaseDate = !!item.releaseDate;
const releaseDate = hasReleaseDate ? parseISO(item.releaseDate) : null;
const releaseDate = hasReleaseDate && item.releaseDate ? parseISO(item.releaseDate) : null;
const formattedDate = releaseDate ? format(releaseDate, 'MMM d, yyyy') : '';
const isFuture = releaseDate ? isAfter(releaseDate, new Date()) : false;
const isAnimeItem = item.id.startsWith('mal:') || item.id.startsWith('kitsu:');
// Use episode still image if available, fallback to series poster
// For AniList items, item.poster is already a full URL
const imageUrl = item.still_path ?
tmdbService.getImageUrl(item.still_path) :
(item.season_poster_path ?
tmdbService.getImageUrl(item.season_poster_path) :
item.poster);
return (
<Animated.View entering={FadeIn.duration(300).delay(100)}>
<TouchableOpacity
@ -142,36 +226,53 @@ const CalendarScreen = () => {
>
<FastImage
source={{ uri: imageUrl || '' }}
style={styles.poster}
style={[
styles.poster,
isAnimeItem && { aspectRatio: 2/3, width: 80, height: 120 }
]}
resizeMode={FastImage.resizeMode.cover}
/>
</TouchableOpacity>
<View style={styles.episodeDetails}>
<Text style={[styles.seriesName, { color: currentTheme.colors.text }]} numberOfLines={1}>
<Text style={[styles.seriesName, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1}>
{item.seriesName}
</Text>
{hasReleaseDate ? (
{(hasReleaseDate || isAnimeItem) ? (
<>
<Text style={[styles.episodeTitle, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
S{item.season}:E{item.episode} - {item.title}
</Text>
{!isAnimeItem && (
<Text style={[styles.episodeTitle, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
S{item.season}:E{item.episode} - {item.title}
</Text>
)}
{item.overview ? (
<Text style={[styles.overview, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
<Text style={[styles.overview, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={2}>
{item.overview}
</Text>
) : null}
{isAnimeItem && item.genres && item.genres.length > 0 && (
<View style={styles.genreContainer}>
{item.genres.slice(0, 3).map((g, i) => (
<View key={i} style={[styles.genreChip, { backgroundColor: currentTheme.colors.primary + '20' }]}>
<Text style={[styles.genreText, { color: currentTheme.colors.primary }]}>{g}</Text>
</View>
))}
</View>
)}
<View style={styles.metadataContainer}>
<View style={styles.dateContainer}>
<MaterialIcons
name={isFuture ? "event" : "event-available"}
name={isFuture || isAnimeItem ? "event" : "event-available"}
size={16}
color={currentTheme.colors.lightGray}
color={currentTheme.colors.primary}
/>
<Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>{formattedDate}</Text>
<Text style={[styles.date, { color: currentTheme.colors.primary, fontWeight: '600' }]}>
{isAnimeItem ? `${item.day} ${item.time || ''}` : formattedDate}
</Text>
</View>
{item.vote_average > 0 && (
@ -179,9 +280,9 @@ const CalendarScreen = () => {
<MaterialIcons
name="star"
size={16}
color={currentTheme.colors.primary}
color="#F5C518"
/>
<Text style={[styles.rating, { color: currentTheme.colors.primary }]}>
<Text style={[styles.rating, { color: '#F5C518' }]}>
{item.vote_average.toFixed(1)}
</Text>
</View>
@ -231,18 +332,38 @@ const CalendarScreen = () => {
</View>
);
};
const renderSourceSwitcher = () => (
<View style={styles.tabContainer}>
<TouchableOpacity
style={[styles.tabButton, calendarSource === 'nuvio' && { backgroundColor: currentTheme.colors.primary }]}
onPress={() => setCalendarSource('nuvio')}
>
<Text style={[styles.tabText, calendarSource === 'nuvio' && { color: '#fff', fontWeight: 'bold' }]}>Nuvio</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tabButton, calendarSource === 'anilist' && { backgroundColor: currentTheme.colors.primary }]}
onPress={() => setCalendarSource('anilist')}
>
<Text style={[styles.tabText, calendarSource === 'anilist' && { color: '#fff', fontWeight: 'bold' }]}>AniList</Text>
</TouchableOpacity>
</View>
);
// Process all episodes once data is loaded - using memory-efficient approach
const allEpisodes = React.useMemo(() => {
if (!uiReady) return [] as CalendarEpisode[];
const episodes = calendarData.reduce((acc: CalendarEpisode[], section: CalendarSection) => {
// Use AniList schedule if selected
const sourceData = calendarSource === 'anilist' ? aniListSchedule : calendarData;
const episodes = sourceData.reduce((acc: CalendarEpisode[], section: CalendarSection) => {
// Pre-trim section arrays defensively
const trimmed = memoryManager.limitArraySize(section.data, 500);
return acc.length > 1500 ? acc : [...acc, ...trimmed];
}, [] as CalendarEpisode[]);
// Global cap to keep memory bounded
return memoryManager.limitArraySize(episodes, 1500);
}, [calendarData, uiReady]);
}, [calendarData, aniListSchedule, uiReady, calendarSource]);
// Log when rendering with relevant state info
logger.log(`[Calendar] Rendering: loading=${loading}, calendarData sections=${calendarData.length}, allEpisodes=${allEpisodes.length}`);
@ -284,7 +405,7 @@ const CalendarScreen = () => {
setFilteredEpisodes([]);
}, []);
if ((loading || !uiReady) && !refreshing) {
if (((loading || aniListLoading) || !uiReady) && !refreshing) {
return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
@ -310,7 +431,11 @@ const CalendarScreen = () => {
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>{t('calendar.title')}</Text>
<View style={{ width: 40 }} />
</View>
{renderSourceSwitcher()}
{calendarSource === 'nuvio' && (
<>
{selectedDate && filteredEpisodes.length > 0 && (
<View style={[styles.filterInfoContainer, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.filterInfoText, { color: currentTheme.colors.text }]}>
@ -326,6 +451,8 @@ const CalendarScreen = () => {
episodes={allEpisodes}
onSelectDate={handleDateSelect}
/>
</>
)}
{selectedDate && filteredEpisodes.length > 0 ? (
<FlatList
@ -362,9 +489,9 @@ const CalendarScreen = () => {
</Text>
</TouchableOpacity>
</View>
) : calendarData.length > 0 ? (
) : (calendarSource === 'anilist' ? aniListSchedule : calendarData).length > 0 ? (
<SectionList
sections={calendarData}
sections={calendarSource === 'anilist' ? aniListSchedule : calendarData}
keyExtractor={(item) => item.id}
renderItem={renderEpisodeItem}
renderSectionHeader={renderSectionHeader}
@ -560,6 +687,41 @@ const styles = StyleSheet.create({
fontSize: 14,
marginBottom: 4,
},
tabContainer: {
flexDirection: 'row',
marginVertical: 12,
paddingHorizontal: 16,
gap: 12,
},
tabButton: {
flex: 1,
paddingVertical: 10,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
tabText: {
fontSize: 14,
fontWeight: '600',
color: 'rgba(255, 255, 255, 0.7)',
},
genreContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
marginTop: 6,
},
genreChip: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
genreText: {
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
},
});
export default CalendarScreen;

View file

@ -211,6 +211,10 @@ const SkeletonLoader = () => {
);
};
import { MalApiService, MalSync, MalAnimeNode } from '../services/mal';
// ... other imports
const LibraryScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -219,8 +223,12 @@ const LibraryScreen = () => {
const { numColumns, itemWidth } = useMemo(() => getGridLayout(width), [width]);
const [loading, setLoading] = useState(true);
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
const [filter, setFilter] = useState<'trakt' | 'movies' | 'series'>('movies');
const [filter, setFilter] = useState<'trakt' | 'movies' | 'series' | 'mal'>('movies');
const [showTraktContent, setShowTraktContent] = useState(false);
const [malList, setMalMalList] = useState<MalAnimeNode[]>([]);
const [malLoading, setMalLoading] = useState(false);
const [malOffset, setMalOffset] = useState(0);
const [hasMoreMal, setHasMoreMal] = useState(true);
const [selectedTraktFolder, setSelectedTraktFolder] = useState<string | null>(null);
const { showInfo, showError } = useToast();
const [menuVisible, setMenuVisible] = useState(false);
@ -800,7 +808,142 @@ const LibraryScreen = () => {
);
};
const renderFilter = (filterType: 'trakt' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => {
const loadMalList = useCallback(async (isLoadMore = false) => {
if (malLoading || (isLoadMore && !hasMoreMal)) return;
const currentOffset = isLoadMore ? malOffset : 0;
setMalLoading(true);
try {
const response = await MalApiService.getUserList(undefined, currentOffset);
if (isLoadMore) {
setMalMalList(prev => [...prev, ...response.data]);
} else {
setMalMalList(response.data);
}
setMalOffset(currentOffset + response.data.length);
setHasMoreMal(!!response.paging.next);
} catch (error) {
logger.error('Failed to load MAL list:', error);
} finally {
setMalLoading(false);
}
}, [malLoading, malOffset, hasMoreMal]);
const renderMalItem = ({ item }: { item: MalAnimeNode }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => navigation.navigate('Metadata', {
id: `mal:${item.node.id}`,
type: item.node.media_type === 'movie' ? 'movie' : 'series'
})}
activeOpacity={0.7}
>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black, borderRadius: settings.posterBorderRadius ?? 12 }]}>
<FastImage
source={{ uri: item.node.main_picture?.large || item.node.main_picture?.medium || 'https://via.placeholder.com/300x450' }}
style={[styles.poster, { borderRadius: settings.posterBorderRadius ?? 12 }]}
resizeMode={FastImage.resizeMode.cover}
/>
<View style={styles.malBadge}>
<Text style={styles.malBadgeText}>{item.list_status.status.replace('_', ' ')}</Text>
</View>
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{
width: `${(item.list_status.num_episodes_watched / (item.node.num_episodes || 1)) * 100}%`,
backgroundColor: '#2E51A2'
}
]}
/>
</View>
</View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={2}>
{item.node.title}
</Text>
<Text style={[styles.malScore, { color: '#F5C518' }]}>
{item.list_status.score > 0 ? item.list_status.score : '-'}
</Text>
</View>
</TouchableOpacity>
);
const renderMalContent = () => {
if (malLoading && malList.length === 0) return <SkeletonLoader />;
if (malList.length === 0) {
return (
<View style={styles.emptyContainer}>
<MaterialIcons name="library-books" size={64} color={currentTheme.colors.lightGray} />
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>Your MAL list is empty</Text>
<TouchableOpacity
style={[styles.exploreButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => loadMalList()}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Refresh</Text>
</TouchableOpacity>
</View>
);
}
const grouped = {
watching: malList.filter(i => i.list_status.status === 'watching'),
plan_to_watch: malList.filter(i => i.list_status.status === 'plan_to_watch'),
completed: malList.filter(i => i.list_status.status === 'completed'),
dropped: malList.filter(i => i.list_status.status === 'dropped'),
on_hold: malList.filter(i => i.list_status.status === 'on_hold'),
};
const sections = [
{ key: 'watching', title: 'Watching', data: grouped.watching },
{ key: 'plan_to_watch', title: 'Plan to Watch', data: grouped.plan_to_watch },
{ key: 'completed', title: 'Completed', data: grouped.completed },
{ key: 'dropped', title: 'Dropped', data: grouped.dropped },
{ key: 'on_hold', title: 'On Hold', data: grouped.on_hold },
];
return (
<ScrollView
contentContainerStyle={[styles.listContainer, { paddingBottom: insets.bottom + 80 }]}
showsVerticalScrollIndicator={false}
onScroll={({ nativeEvent }) => {
if (isCloseToBottom(nativeEvent) && hasMoreMal) {
loadMalList(true);
}
}}
scrollEventThrottle={400}
>
{sections.map(section => (
section.data.length > 0 && (
<View key={section.key} style={styles.malSectionContainer}>
<Text style={[styles.malSectionHeader, { color: currentTheme.colors.highEmphasis }]}>
{section.title} <Text style={{ color: currentTheme.colors.mediumEmphasis, fontSize: 14 }}>({section.data.length})</Text>
</Text>
<View style={styles.malSectionGrid}>
{section.data.map(item => (
<View key={item.node.id} style={{ marginBottom: 16 }}>
{renderMalItem({ item })}
</View>
))}
</View>
</View>
)
))}
{malLoading && (
<ActivityIndicator color={currentTheme.colors.primary} style={{ marginTop: 20 }} />
)}
</ScrollView>
);
};
const isCloseToBottom = ({ layoutMeasurement, contentOffset, contentSize }: any) => {
const paddingToBottom = 20;
return layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom;
};
const renderFilter = (filterType: 'trakt' | 'movies' | 'series' | 'mal', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => {
const isActive = filter === filterType;
return (
@ -821,6 +964,13 @@ const LibraryScreen = () => {
}
return;
}
if (filterType === 'mal') {
setShowTraktContent(false);
setFilter('mal');
loadMalList();
return;
}
setShowTraktContent(false);
setFilter(filterType);
}}
activeOpacity={0.7}
@ -930,14 +1080,20 @@ const LibraryScreen = () => {
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{!showTraktContent && (
<View style={styles.filtersContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.filtersContainer}
contentContainerStyle={styles.filtersContent}
>
{renderFilter('trakt', 'Trakt', 'pan-tool')}
{renderFilter('mal', 'MAL', 'book')}
{renderFilter('movies', t('search.movies'), 'movie')}
{renderFilter('series', t('search.tv_shows'), 'live-tv')}
</View>
</ScrollView>
)}
{showTraktContent ? renderTraktContent() : renderContent()}
{showTraktContent ? renderTraktContent() : (filter === 'mal' ? renderMalContent() : renderContent())}
</View>
{selectedItem && (
@ -1012,15 +1168,18 @@ const styles = StyleSheet.create({
flex: 1,
},
filtersContainer: {
flexDirection: 'row',
justifyContent: 'center',
paddingHorizontal: 16,
paddingBottom: 16,
paddingTop: 8,
flexGrow: 0,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.05)',
zIndex: 10,
},
filtersContent: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingBottom: 16,
paddingTop: 8,
},
filterButton: {
flexDirection: 'row',
alignItems: 'center',
@ -1287,6 +1446,41 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
malBadge: {
position: 'absolute',
top: 8,
left: 8,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
malBadgeText: {
color: '#fff',
fontSize: 10,
fontWeight: 'bold',
textTransform: 'uppercase',
},
malScore: {
fontSize: 12,
fontWeight: 'bold',
marginTop: 2,
textAlign: 'center',
},
malSectionContainer: {
marginBottom: 24,
},
malSectionHeader: {
fontSize: 18,
fontWeight: '700',
marginBottom: 12,
paddingHorizontal: 4,
},
malSectionGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
});
export default LibraryScreen;

View file

@ -0,0 +1,356 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
SafeAreaView,
ScrollView,
StatusBar,
Platform,
Switch,
Image,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import FastImage from '@d11/react-native-fast-image';
import { MalAuth } from '../services/mal/MalAuth';
import { MalApiService } from '../services/mal/MalApi';
import { mmkvStorage } from '../services/mmkvStorage';
import { MalUser } from '../types/mal';
import { useTheme } from '../contexts/ThemeContext';
import { colors } from '../styles';
import CustomAlert from '../components/CustomAlert';
import { useTranslation } from 'react-i18next';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const MalSettingsScreen: React.FC = () => {
const { t } = useTranslation();
const navigation = useNavigation();
const { currentTheme } = useTheme();
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [userProfile, setUserProfile] = useState<MalUser | null>(null);
const [syncEnabled, setSyncEnabled] = useState(mmkvStorage.getBoolean('mal_enabled') ?? true);
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(mmkvStorage.getBoolean('mal_auto_update') ?? true);
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<Array<{ label: string; onPress: () => void }>>([]);
const openAlert = (title: string, message: string, actions?: any[]) => {
setAlertTitle(title);
setAlertMessage(message);
setAlertActions(actions || [{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true);
};
const checkAuthStatus = useCallback(async () => {
setIsLoading(true);
try {
// Initialize Auth (loads from storage)
const token = MalAuth.getToken();
if (token && !MalAuth.isTokenExpired(token)) {
setIsAuthenticated(true);
// Fetch Profile
const profile = await MalApiService.getUserInfo();
setUserProfile(profile);
} else if (token && MalAuth.isTokenExpired(token)) {
// Try refresh
const refreshed = await MalAuth.refreshToken();
if (refreshed) {
setIsAuthenticated(true);
const profile = await MalApiService.getUserInfo();
setUserProfile(profile);
} else {
setIsAuthenticated(false);
setUserProfile(null);
}
} else {
setIsAuthenticated(false);
setUserProfile(null);
}
} catch (error) {
console.error('[MalSettings] Auth check failed', error);
setIsAuthenticated(false);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
checkAuthStatus();
}, [checkAuthStatus]);
const handleSignIn = async () => {
setIsLoading(true);
try {
const result = await MalAuth.login();
if (result === true) {
await checkAuthStatus();
openAlert('Success', 'Connected to MyAnimeList');
} else {
const errorMessage = typeof result === 'string' ? result : 'Failed to connect to MyAnimeList';
openAlert('Error', errorMessage);
}
} catch (e: any) {
console.error(e);
openAlert('Error', `An error occurred during sign in: ${e.message || 'Unknown error'}`);
} finally {
setIsLoading(false);
}
};
const handleSignOut = () => {
openAlert('Sign Out', 'Are you sure you want to disconnect?', [
{ label: 'Cancel', onPress: () => setAlertVisible(false) },
{
label: 'Sign Out',
onPress: () => {
MalAuth.clearToken();
setIsAuthenticated(false);
setUserProfile(null);
setAlertVisible(false);
}
}
]);
};
const toggleSync = (val: boolean) => {
setSyncEnabled(val);
mmkvStorage.setBoolean('mal_enabled', val);
};
const toggleAutoUpdate = (val: boolean) => {
setAutoUpdateEnabled(val);
mmkvStorage.setBoolean('mal_auto_update', val);
};
return (
<SafeAreaView style={[
styles.container,
{ backgroundColor: currentTheme.colors.darkBackground }
]}>
<StatusBar barStyle={'light-content'} />
<View style={styles.header}>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<MaterialIcons
name="arrow-back"
size={24}
color={currentTheme.colors.highEmphasis}
/>
<Text style={[styles.backText, { color: currentTheme.colors.highEmphasis }]}>
Settings
</Text>
</TouchableOpacity>
</View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
MyAnimeList
</Text>
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
</View>
) : isAuthenticated && userProfile ? (
<View style={styles.profileContainer}>
<View style={styles.profileHeader}>
{userProfile.picture ? (
<FastImage
source={{ uri: userProfile.picture }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatarPlaceholder, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.avatarText}>{userProfile.name.charAt(0)}</Text>
</View>
)}
<View style={styles.profileInfo}>
<Text style={[styles.profileName, { color: currentTheme.colors.highEmphasis }]}>
{userProfile.name}
</Text>
<Text style={[styles.profileUsername, { color: currentTheme.colors.mediumEmphasis }]}>
ID: {userProfile.id}
</Text>
</View>
</View>
<TouchableOpacity
style={[styles.button, styles.signOutButton, { backgroundColor: currentTheme.colors.error }]}
onPress={handleSignOut}
>
<Text style={styles.buttonText}>Sign Out</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.signInContainer}>
<Image
source={require('../../assets/rating-icons/mal-icon.png')}
style={{ width: 80, height: 80, marginBottom: 16, borderRadius: 16 }}
resizeMode="contain"
/>
<Text style={[styles.signInTitle, { color: currentTheme.colors.highEmphasis }]}>
Connect MyAnimeList
</Text>
<Text style={[styles.signInDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Sync your watch history and manage your anime list.
</Text>
<View style={[styles.noteContainer, { backgroundColor: currentTheme.colors.primary + '15', borderColor: currentTheme.colors.primary + '30' }]}>
<MaterialIcons name="info-outline" size={18} color={currentTheme.colors.primary} />
<Text style={[styles.noteText, { color: currentTheme.colors.highEmphasis }]}>
MAL sync only works with the <Text style={{ fontWeight: 'bold' }}>AnimeKitsu</Text> catalog items.
</Text>
</View>
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleSignIn}
>
<Text style={styles.buttonText}>Sign In with MAL</Text>
</TouchableOpacity>
</View>
)}
</View>
{isAuthenticated && (
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.settingsSection}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
Sync Settings
</Text>
<View style={styles.settingItem}>
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
Enable Sync
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Sync watch status to MyAnimeList
</Text>
</View>
<Switch
value={syncEnabled}
onValueChange={toggleSync}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={syncEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
/>
</View>
</View>
<View style={styles.settingItem}>
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
Auto Episode Update
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Automatically update episode progress when watching
</Text>
</View>
<Switch
value={autoUpdateEnabled}
onValueChange={toggleAutoUpdate}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={autoUpdateEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
/>
</View>
</View>
</View>
</View>
)}
</ScrollView>
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
onClose={() => setAlertVisible(false)}
actions={alertActions}
/>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
},
backButton: { flexDirection: 'row', alignItems: 'center', padding: 8 },
backText: { fontSize: 17, marginLeft: 8 },
headerTitle: {
fontSize: 34,
fontWeight: 'bold',
paddingHorizontal: 16,
marginBottom: 24,
},
scrollView: { flex: 1 },
scrollContent: { paddingHorizontal: 16, paddingBottom: 32 },
card: {
borderRadius: 12,
overflow: 'hidden',
marginBottom: 16,
elevation: 2,
},
loadingContainer: { padding: 40, alignItems: 'center' },
signInContainer: { padding: 24, alignItems: 'center' },
signInTitle: { fontSize: 20, fontWeight: '600', marginBottom: 8 },
signInDescription: { fontSize: 15, textAlign: 'center', marginBottom: 24 },
button: {
width: '100%',
height: 44,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
},
buttonText: { fontSize: 16, fontWeight: '500', color: 'white' },
profileContainer: { padding: 20 },
profileHeader: { flexDirection: 'row', alignItems: 'center' },
avatar: { width: 64, height: 64, borderRadius: 32 },
avatarPlaceholder: { width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center' },
avatarText: { fontSize: 24, color: 'white', fontWeight: 'bold' },
profileInfo: { marginLeft: 16, flex: 1 },
profileName: { fontSize: 18, fontWeight: '600' },
profileUsername: { fontSize: 14 },
signOutButton: { marginTop: 20 },
settingsSection: { padding: 20 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 16 },
settingItem: { marginBottom: 16 },
settingContent: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
settingTextContainer: { flex: 1, marginRight: 16 },
settingLabel: { fontSize: 15, fontWeight: '500', marginBottom: 4 },
settingDescription: { fontSize: 14 },
noteContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 8,
borderWidth: 1,
marginBottom: 20,
marginTop: -8,
},
noteText: {
fontSize: 13,
marginLeft: 8,
flex: 1,
lineHeight: 18,
},
});
export default MalSettingsScreen;

View file

@ -85,17 +85,18 @@ const MemoizedRatingsSection = memo(RatingsSection);
const MemoizedCommentsSection = memo(CommentsSection);
const MemoizedCastDetailsModal = memo(CastDetailsModal);
import { MalAuth } from '../services/mal/MalAuth';
import { MalSync } from '../services/mal/MalSync';
import { MalScoreModal } from '../components/metadata/MalScoreModal';
// ... other imports
const MetadataScreen: React.FC = () => {
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string; addonId?: string }>, string>>();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, 'Metadata'>>();
const { id, type, episodeId, addonId } = route.params;
const { t } = useTranslation();
// Log route parameters for debugging
React.useEffect(() => {
console.log('🔍 [MetadataScreen] Route params:', { id, type, episodeId, addonId });
}, [id, type, episodeId, addonId]);
// Consolidated hooks for better performance
const { settings } = useSettings();
const { currentTheme } = useTheme();
@ -105,6 +106,61 @@ const MetadataScreen: React.FC = () => {
// Trakt integration
const { isAuthenticated, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection } = useTraktContext();
const {
metadata,
loading,
error: metadataError,
cast,
loadingCast,
episodes,
selectedSeason,
loadingSeasons,
loadMetadata,
handleSeasonChange,
toggleLibrary,
inLibrary,
groupedEpisodes,
recommendations,
loadingRecommendations,
setMetadata,
imdbId,
tmdbId,
collectionMovies,
loadingCollection,
} = useMetadata({ id, type, addonId });
const [malModalVisible, setMalModalVisible] = useState(false);
const [malId, setMalId] = useState<number | null>(null);
const isMalAuthenticated = !!MalAuth.getToken();
useEffect(() => {
// STRICT MODE: Only enable MAL features if the content source is explicitly Anime (MAL/Kitsu)
// This prevents "fuzzy match" errors where Cinemeta shows get mapped to random anime or wrong seasons.
const isAnimeSource = id && (id.startsWith('mal:') || id.startsWith('kitsu:') || id.includes(':mal:') || id.includes(':kitsu:'));
if (isMalAuthenticated && metadata?.name && isAnimeSource) {
// If it's a MAL source, extract ID directly
if (id.startsWith('mal:')) {
const directId = parseInt(id.split(':')[1], 10);
if (!isNaN(directId)) {
setMalId(directId);
return;
}
}
// Otherwise resolve (e.g. Kitsu -> MAL)
MalSync.getMalId(metadata.name, Object.keys(groupedEpisodes).length > 0 ? 'series' : 'movie')
.then(id => setMalId(id));
} else {
setMalId(null);
}
}, [isMalAuthenticated, metadata, groupedEpisodes, id]);
// Log route parameters for debugging
React.useEffect(() => {
console.log('🔍 [MetadataScreen] Route params:', { id, type, episodeId, addonId });
}, [id, type, episodeId, addonId]);
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
@ -170,30 +226,6 @@ const MetadataScreen: React.FC = () => {
console.log('MetadataScreen: selectedComment changed to:', selectedComment?.id);
}, [selectedComment]);
const {
metadata,
loading,
error: metadataError,
cast,
loadingCast,
episodes,
selectedSeason,
loadingSeasons,
loadMetadata,
handleSeasonChange,
toggleLibrary,
inLibrary,
groupedEpisodes,
recommendations,
loadingRecommendations,
setMetadata,
imdbId,
tmdbId,
collectionMovies,
loadingCollection,
} = useMetadata({ id, type, addonId });
// Log useMetadata hook state changes for debugging
React.useEffect(() => {
console.log('🔍 [MetadataScreen] useMetadata state:', {
@ -997,6 +1029,8 @@ const MetadataScreen: React.FC = () => {
dynamicBackgroundColor={dynamicBackgroundColor}
handleBack={handleBack}
tmdbId={tmdbId}
malId={malId}
onMalPress={() => setMalModalVisible(true)}
/>
{/* Main Content - Optimized */}
@ -1424,6 +1458,19 @@ const MetadataScreen: React.FC = () => {
isSpoilerRevealed={selectedComment ? revealedSpoilers.has(selectedComment.id.toString()) : false}
onSpoilerPress={() => selectedComment && handleSpoilerPress(selectedComment)}
/>
{malId && (
<MalScoreModal
visible={malModalVisible}
onClose={() => setMalModalVisible(false)}
malId={malId}
animeTitle={metadata?.name || ''}
seasons={Object.keys(groupedEpisodes).map(Number)}
currentSeason={selectedSeason}
imdbId={imdbId || undefined}
type={Object.keys(groupedEpisodes).length > 0 ? 'series' : type as 'movie' | 'series'}
/>
)}
</AnimatedSafeAreaView>
</Animated.View>
);

View file

@ -153,18 +153,13 @@ const SearchScreen = () => {
type: catalog.type,
};
await mmkvStorage.setItem(DISCOVER_CATALOG_KEY, JSON.stringify(catalogData));
} else {
// Clear catalog if null
await mmkvStorage.removeItem(DISCOVER_CATALOG_KEY);
}
// Save genre - use empty string to indicate "All genres"
// This way we distinguish between "not set" and "All genres"
// Save genre
if (genre) {
await mmkvStorage.setItem(DISCOVER_GENRE_KEY, genre);
} else {
// Save empty string to indicate "All genres" is selected
await mmkvStorage.setItem(DISCOVER_GENRE_KEY, '');
await mmkvStorage.removeItem(DISCOVER_GENRE_KEY);
}
} catch (error) {
logger.error('Failed to save discover settings:', error);
@ -193,21 +188,11 @@ const SearchScreen = () => {
// Load saved genre
const savedGenre = await mmkvStorage.getItem(DISCOVER_GENRE_KEY);
if (savedGenre !== null) {
if (savedGenre === '') {
// Empty string means "All genres" was selected
setSelectedDiscoverGenre(null);
} else if (foundCatalog.genres.includes(savedGenre)) {
setSelectedDiscoverGenre(savedGenre);
} else if (foundCatalog.genres.length > 0) {
// Set first genre as default if saved genre not available
setSelectedDiscoverGenre(foundCatalog.genres[0]);
}
} else {
// No saved genre, default to first genre
if (foundCatalog.genres.length > 0) {
setSelectedDiscoverGenre(foundCatalog.genres[0]);
}
if (savedGenre && foundCatalog.genres.includes(savedGenre)) {
setSelectedDiscoverGenre(savedGenre);
} else if (foundCatalog.genres.length > 0) {
// Set first genre as default if saved genre not available
setSelectedDiscoverGenre(foundCatalog.genres[0]);
}
return;
}
@ -703,7 +688,7 @@ const SearchScreen = () => {
const handleGenreSelect = (genre: string | null) => {
setSelectedDiscoverGenre(genre);
// Save genre setting - this will save empty string for null (All genres)
// Save genre setting
saveDiscoverSettings(selectedDiscoverType, selectedCatalog, genre);
genreSheetRef.current?.dismiss();

View file

@ -13,6 +13,7 @@ import {
Dimensions,
Linking,
FlatList,
Image,
} from 'react-native';
import { BottomSheetModal, BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { useTranslation } from 'react-i18next';
@ -378,10 +379,18 @@ const SettingsScreen: React.FC = () => {
customIcon={<TraktIcon size={isTablet ? 24 : 20} color={currentTheme.colors.primary} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')}
isLast={true}
isTablet={isTablet}
/>
)}
<SettingItem
title="MyAnimeList"
description="Sync with MyAnimeList"
customIcon={<Image source={require('../../assets/rating-icons/mal-icon.png')} style={{ width: isTablet ? 24 : 20, height: isTablet ? 24 : 20, borderRadius: 4 }} resizeMode="contain" />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('MalSettings')}
isLast={true}
isTablet={isTablet}
/>
</SettingsCard>
);
@ -672,9 +681,16 @@ const SettingsScreen: React.FC = () => {
customIcon={<TraktIcon size={20} color={currentTheme.colors.primary} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')}
isLast
/>
)}
<SettingItem
title="MyAnimeList"
description="Sync with MyAnimeList"
customIcon={<Image source={require('../../assets/rating-icons/mal-icon.png')} style={{ width: 20, height: 20, borderRadius: 4 }} resizeMode="contain" />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('MalSettings')}
isLast
/>
</SettingsCard>
)}

View file

@ -638,8 +638,7 @@ export const useStreamsScreen = () => {
hasDoneInitialLoadRef.current = true;
try {
const stremioType = type === 'tv' ? 'series' : type;
const hasStremioProviders = await stremioService.hasStreamProviders(stremioType);
const hasStremioProviders = await stremioService.hasStreamProviders(type);
const hasLocalScrapers = settings.enableLocalScrapers && (await localScraperService.hasScrapers());
const hasProviders = hasStremioProviders || hasLocalScrapers;

View file

@ -0,0 +1,219 @@
import * as FileSystem from 'expo-file-system';
import axios from 'axios';
import { Asset } from 'expo-asset';
// We require the bundled mappings as a fallback.
// This ensures the app works immediately upon install without internet.
const BUNDLED_MAPPINGS = require('../assets/mappings.json');
const MAPPINGS_FILE_URI = FileSystem.documentDirectory + 'mappings.json';
const GITHUB_RAW_URL = 'https://raw.githubusercontent.com/eliasbenb/PlexAniBridge-Mappings/master/mappings.json';
interface MappingEntry {
anidb_id?: number;
imdb_id?: string | string[];
mal_id?: number | number[];
tmdb_show_id?: number;
tmdb_movie_id?: number | number[];
tvdb_id?: number;
tvdb_mappings?: { [key: string]: string };
}
interface Mappings {
[anilist_id: string]: MappingEntry;
}
class MappingService {
private mappings: Mappings = {};
private imdbIndex: { [imdbId: string]: string[] } = {}; // Maps IMDb ID to array of AniList IDs
private isInitialized = false;
/**
* Initialize the service. Loads mappings from local storage if available,
* otherwise falls back to the bundled JSON.
*/
async init() {
if (this.isInitialized) return;
try {
const fileInfo = await FileSystem.getInfoAsync(MAPPINGS_FILE_URI);
if (fileInfo.exists) {
console.log('Loading mappings from local storage...');
const content = await FileSystem.readAsStringAsync(MAPPINGS_FILE_URI);
this.mappings = JSON.parse(content);
} else {
console.log('Loading bundled mappings...');
this.mappings = BUNDLED_MAPPINGS;
}
} catch (error) {
console.error('Failed to load mappings, falling back to bundled:', error);
this.mappings = BUNDLED_MAPPINGS;
}
this.buildIndex();
this.isInitialized = true;
console.log(`MappingService initialized with ${Object.keys(this.mappings).length} entries.`);
// Trigger background update
this.checkForUpdates().catch(err => console.warn('Background mapping update failed:', err));
}
/**
* Build a reverse index for fast IMDb lookups.
*/
private buildIndex() {
this.imdbIndex = {};
for (const [anilistId, entry] of Object.entries(this.mappings)) {
if (entry.imdb_id) {
const imdbIds = Array.isArray(entry.imdb_id) ? entry.imdb_id : [entry.imdb_id];
for (const id of imdbIds) {
if (!this.imdbIndex[id]) {
this.imdbIndex[id] = [];
}
this.imdbIndex[id].push(anilistId);
}
}
}
}
/**
* Check for updates from the GitHub repository and save to local storage.
*/
async checkForUpdates() {
try {
console.log('Checking for mapping updates...');
const response = await axios.get(GITHUB_RAW_URL);
if (response.data && typeof response.data === 'object') {
const newMappings = response.data;
const newCount = Object.keys(newMappings).length;
const currentCount = Object.keys(this.mappings).length;
// Basic sanity check: ensure we didn't download an empty or drastically smaller file
if (newCount > 1000) {
await FileSystem.writeAsStringAsync(MAPPINGS_FILE_URI, JSON.stringify(newMappings));
console.log(`Mappings updated successfully. New count: ${newCount} (Old: ${currentCount})`);
// Optional: Hot-reload the mappings immediately?
// For stability, usually better to wait for next app restart,
// but we can update in memory too.
this.mappings = newMappings;
this.buildIndex();
}
}
} catch (error) {
console.warn('Failed to update mappings:', error);
}
}
/**
* Convert an IMDb ID + Season/Episode to a MAL ID.
* Handles complex mapping logic (split seasons, episode offsets).
*/
getMalId(imdbId: string, season: number, episode: number): number | null {
if (!this.isInitialized) {
console.warn('MappingService not initialized. Call init() first.');
}
const anilistIds = this.imdbIndex[imdbId];
if (!anilistIds || anilistIds.length === 0) return null;
// Iterate through all potential matches (usually just 1, but sometimes splits)
for (const anilistId of anilistIds) {
const entry = this.mappings[anilistId];
if (!entry) continue;
// If there are no specific mappings, assumes 1:1 match if it's the only entry
// But usually, we look for 'tvdb_mappings' (which this repo uses for seasons)
// or 'tmdb_mappings'. This repo uses 'tvdb_mappings' for structure.
if (this.isMatch(entry, season, episode)) {
return this.extractMalId(entry);
}
}
// Fallback: If we have exactly one match and no mapping rules defined, return it.
if (anilistIds.length === 1) {
const entry = this.mappings[anilistIds[0]];
// Only return if it doesn't have restrictive mapping rules that failed above
if (!entry.tvdb_mappings) {
return this.extractMalId(entry);
}
}
return null;
}
private extractMalId(entry: MappingEntry): number | null {
if (!entry.mal_id) return null;
if (Array.isArray(entry.mal_id)) return entry.mal_id[0];
return entry.mal_id;
}
/**
* Logic to check if a specific Season/Episode fits within the entry's mapping rules.
*/
private isMatch(entry: MappingEntry, targetSeason: number, targetEpisode: number): boolean {
const mappings = entry.tvdb_mappings;
if (!mappings) {
// If no mappings exist, we can't be sure, but usually strict matching requires them.
// However, some entries might be simple movies or single seasons.
return true;
}
const seasonKey = `s${targetSeason}`;
const rule = mappings[seasonKey];
if (rule === undefined) return false; // Season not in this entry
// Empty string means "matches whole season 1:1"
if (rule === "") return true;
// Parse rules: "e1-e12|2,e13-"
const parts = rule.split(',');
for (const part of parts) {
if (this.checkRulePart(part, targetEpisode)) {
return true;
}
}
return false;
}
private checkRulePart(part: string, targetEpisode: number): boolean {
// Format: e{start}-e{end}|{ratio}
// Examples: "e1-e12", "e13-", "e1", "e1-e12|2"
let [range, ratioStr] = part.split('|');
// We currently ignore ratio for *matching* purposes (just checking if it's in range)
// Ratio is used for calculating the absolute episode number if we were converting TO absolute.
const [startStr, endStr] = range.split('-');
const start = parseInt(startStr.replace('e', ''), 10);
// Single episode mapping: "e5"
if (!endStr && !range.includes('-')) {
return targetEpisode === start;
}
// Range
if (targetEpisode < start) return false;
// Open ended range: "e13-"
if (endStr === '') {
return true;
}
// Closed range: "e1-e12"
if (endStr) {
const end = parseInt(endStr.replace('e', ''), 10);
if (targetEpisode > end) return false;
}
return true;
}
}
export const mappingService = new MappingService();

View file

@ -0,0 +1,91 @@
import EventEmitter from 'eventemitter3';
import { logger } from '../utils/logger';
// Events for communication between Service and Component
export const EXTRACTOR_EVENTS = {
START_EXTRACTION: 'start_extraction',
EXTRACTION_SUCCESS: 'extraction_success',
EXTRACTION_FAILURE: 'extraction_failed',
};
interface ExtractionRequest {
id: string;
url: string;
script?: string;
headers?: Record<string, string>;
}
interface ExtractionResult {
id: string;
streamUrl?: string;
headers?: Record<string, string>;
error?: string;
}
class StreamExtractorService {
private static instance: StreamExtractorService;
public events = new EventEmitter();
private pendingRequests = new Map<string, { resolve: (val: any) => void; reject: (err: any) => void; timeout: NodeJS.Timeout }>();
private constructor() {
// Listen for results from the component
this.events.on(EXTRACTOR_EVENTS.EXTRACTION_SUCCESS, this.handleSuccess.bind(this));
this.events.on(EXTRACTOR_EVENTS.EXTRACTION_FAILURE, this.handleFailure.bind(this));
}
static getInstance(): StreamExtractorService {
if (!StreamExtractorService.instance) {
StreamExtractorService.instance = new StreamExtractorService();
}
return StreamExtractorService.instance;
}
/**
* Extracts a direct stream URL from an embed URL using a hidden WebView.
* @param url The embed URL to load.
* @param script Optional custom JavaScript to run in the WebView.
* @param timeoutMs Timeout in milliseconds (default 15000).
* @returns Promise resolving to the stream URL or object with url and headers.
*/
public async extractStream(url: string, script?: string, timeoutMs = 15000): Promise<{ streamUrl: string; headers?: Record<string, string> } | null> {
const id = Math.random().toString(36).substring(7);
logger.log(`[StreamExtractor] Starting extraction for ${url} (ID: ${id})`);
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.finishRequest(id, undefined, 'Timeout waiting for stream extraction');
}, timeoutMs);
this.pendingRequests.set(id, { resolve, reject, timeout });
// Emit event for the component to pick up
this.events.emit(EXTRACTOR_EVENTS.START_EXTRACTION, { id, url, script });
});
}
private handleSuccess(result: ExtractionResult) {
logger.log(`[StreamExtractor] Extraction success for ID: ${result.id}`);
this.finishRequest(result.id, { streamUrl: result.streamUrl, headers: result.headers });
}
private handleFailure(result: ExtractionResult) {
logger.log(`[StreamExtractor] Extraction failed for ID: ${result.id}: ${result.error}`);
this.finishRequest(result.id, undefined, result.error);
}
private finishRequest(id: string, data?: any, error?: string) {
const pending = this.pendingRequests.get(id);
if (pending) {
clearTimeout(pending.timeout);
this.pendingRequests.delete(id);
if (error) {
pending.reject(new Error(error));
} else {
pending.resolve(data);
}
}
}
}
export const streamExtractorService = StreamExtractorService.getInstance();
export default streamExtractorService;

View file

@ -0,0 +1,83 @@
import axios from 'axios';
import { AniListResponse, AniListAiringSchedule } from './types';
import { logger } from '../../utils/logger';
const ANILIST_API_URL = 'https://graphql.anilist.co';
const AIRING_SCHEDULE_QUERY = `
query ($start: Int, $end: Int, $page: Int) {
Page(page: $page, perPage: 50) {
pageInfo {
hasNextPage
total
}
airingSchedules(airingAt_greater: $start, airingAt_lesser: $end, sort: TIME) {
id
airingAt
episode
media {
id
idMal
title {
romaji
english
native
}
coverImage {
large
medium
color
}
episodes
format
status
season
seasonYear
nextAiringEpisode {
airingAt
timeUntilAiring
episode
}
}
}
}
}
`;
export const AniListService = {
getWeeklySchedule: async (): Promise<AniListAiringSchedule[]> => {
try {
const start = Math.floor(Date.now() / 1000);
const end = start + 7 * 24 * 60 * 60; // Next 7 days
let allSchedules: AniListAiringSchedule[] = [];
let page = 1;
let hasNextPage = true;
while (hasNextPage) {
const response = await axios.post<AniListResponse>(ANILIST_API_URL, {
query: AIRING_SCHEDULE_QUERY,
variables: {
start,
end,
page,
},
});
const data = response.data.data.Page;
allSchedules = [...allSchedules, ...data.airingSchedules];
hasNextPage = data.pageInfo.hasNextPage;
page++;
// Safety break to prevent infinite loops if something goes wrong
if (page > 10) break;
}
return allSchedules;
} catch (error) {
logger.error('[AniListService] Failed to fetch weekly schedule:', error);
throw error;
}
},
};

View file

@ -0,0 +1,44 @@
export interface AniListAiringSchedule {
id: number;
airingAt: number; // UNIX timestamp
episode: number;
media: {
id: number;
idMal: number | null;
title: {
romaji: string;
english: string | null;
native: string;
};
coverImage: {
large: string;
medium: string;
color: string | null;
};
episodes: number | null;
format: string; // TV, MOVIE, OVA, ONA, etc.
status: string;
season: string | null;
seasonYear: number | null;
nextAiringEpisode: {
airingAt: number;
timeUntilAiring: number;
episode: number;
} | null;
};
}
export interface AniListResponse {
data: {
Page: {
pageInfo: {
total: number;
perPage: number;
currentPage: number;
lastPage: number;
hasNextPage: boolean;
};
airingSchedules: AniListAiringSchedule[];
};
};
}

113
src/services/mal/MalApi.ts Normal file
View file

@ -0,0 +1,113 @@
import axios from 'axios';
import { MalAuth } from './MalAuth';
import { MalAnimeNode, MalListStatus, MalUserListResponse, MalSearchResult, MalUser } from '../../types/mal';
const CLIENT_ID = '4631b11b52008b79c9a05d63996fc5f8';
const api = axios.create({
baseURL: 'https://api.myanimelist.net/v2',
headers: {
'X-MAL-CLIENT-ID': CLIENT_ID,
},
});
api.interceptors.request.use(async (config) => {
const token = MalAuth.getToken();
if (token) {
if (MalAuth.isTokenExpired(token)) {
const refreshed = await MalAuth.refreshToken();
if (refreshed) {
const newToken = MalAuth.getToken();
if (newToken) {
config.headers.Authorization = `Bearer ${newToken.accessToken}`;
}
}
} else {
config.headers.Authorization = `Bearer ${token.accessToken}`;
}
}
return config;
});
export const MalApiService = {
getUserList: async (status?: MalListStatus, offset = 0, limit = 100): Promise<MalUserListResponse> => {
try {
const response = await api.get('/users/@me/animelist', {
params: {
status,
fields: 'list_status{score,num_episodes_watched,status},num_episodes,media_type,start_season',
limit,
offset,
sort: 'list_updated_at',
},
});
return response.data;
} catch (error) {
console.error('Failed to fetch MAL user list', error);
throw error;
}
},
searchAnime: async (query: string, limit = 5): Promise<MalSearchResult> => {
try {
const response = await api.get('/anime', {
params: { q: query, limit },
});
return response.data;
} catch (error) {
console.error('Failed to search MAL anime', error);
throw error;
}
},
updateStatus: async (
malId: number,
status: MalListStatus,
episode: number,
score?: number
) => {
const data: any = {
status,
num_watched_episodes: episode,
};
if (score && score > 0) data.score = score;
return api.put(`/anime/${malId}/my_list_status`, new URLSearchParams(data).toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
},
getAnimeDetails: async (malId: number) => {
try {
const response = await api.get(`/anime/${malId}`, {
params: { fields: 'id,title,main_picture,num_episodes,start_season,media_type' }
});
return response.data;
} catch (error) {
console.error('Failed to get anime details', error);
throw error;
}
},
getUserInfo: async (): Promise<MalUser> => {
try {
const response = await api.get('/users/@me');
return response.data;
} catch (error) {
console.error('Failed to get user info', error);
throw error;
}
},
getMyListStatus: async (malId: number): Promise<{ list_status?: any; num_episodes: number }> => {
try {
const response = await api.get(`/anime/${malId}`, {
params: { fields: 'my_list_status,num_episodes' }
});
return response.data;
} catch (error) {
console.error('Failed to get my list status', error);
return { num_episodes: 0 };
}
}
};

256
src/services/mal/MalAuth.ts Normal file
View file

@ -0,0 +1,256 @@
import * as WebBrowser from 'expo-web-browser';
import * as Crypto from 'expo-crypto';
import { mmkvStorage } from '../mmkvStorage';
import { MalToken } from '../../types/mal';
const CLIENT_ID = '4631b11b52008b79c9a05d63996fc5f8';
const REDIRECT_URI = 'nuvio://auth';
const KEYS = {
ACCESS: 'mal_access_token',
REFRESH: 'mal_refresh_token',
EXPIRES: 'mal_expires_in',
CREATED: 'mal_created_at',
};
const discovery = {
authorizationEndpoint: 'https://myanimelist.net/v1/oauth2/authorize',
tokenEndpoint: 'https://myanimelist.net/v1/oauth2/token',
};
class MalAuthService {
private static instance: MalAuthService;
private token: MalToken | null = null;
private isAuthenticating = false;
private constructor() {}
static getInstance() {
if (!MalAuthService.instance) {
MalAuthService.instance = new MalAuthService();
}
return MalAuthService.instance;
}
getToken(): MalToken | null {
if (!this.token) {
const access = mmkvStorage.getString(KEYS.ACCESS);
if (access) {
this.token = {
accessToken: access,
refreshToken: mmkvStorage.getString(KEYS.REFRESH) || '',
expiresIn: mmkvStorage.getNumber(KEYS.EXPIRES) || 0,
createdAt: mmkvStorage.getNumber(KEYS.CREATED) || 0,
};
}
}
return this.token;
}
saveToken(token: MalToken) {
this.token = token;
mmkvStorage.setString(KEYS.ACCESS, token.accessToken);
mmkvStorage.setString(KEYS.REFRESH, token.refreshToken);
mmkvStorage.setNumber(KEYS.EXPIRES, token.expiresIn);
mmkvStorage.setNumber(KEYS.CREATED, token.createdAt);
}
clearToken() {
this.token = null;
mmkvStorage.delete(KEYS.ACCESS);
mmkvStorage.delete(KEYS.REFRESH);
mmkvStorage.delete(KEYS.EXPIRES);
mmkvStorage.delete(KEYS.CREATED);
}
isTokenExpired(token: MalToken): boolean {
const now = Date.now();
const expiryTime = token.createdAt + (token.expiresIn * 1000);
// Buffer of 5 minutes
return now > (expiryTime - 300000);
}
private generateCodeVerifier(): string {
const length = 128;
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
let result = '';
const randomBytes = Crypto.getRandomBytes(length);
for (let i = 0; i < length; i++) {
result += charset[randomBytes[i] % charset.length];
}
return result;
}
private async exchangeToken(code: string, codeVerifier: string, uri: string) {
console.log(`[MalAuth] Attempting token exchange with redirect_uri: '${uri}'`);
const params = new URLSearchParams();
params.append('client_id', CLIENT_ID);
params.append('grant_type', 'authorization_code');
params.append('code', code);
params.append('redirect_uri', uri);
params.append('code_verifier', codeVerifier);
const response = await fetch(discovery.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': 'Nuvio-Mobile-App',
},
body: params.toString(),
});
// Handle non-JSON responses safely
const text = await response.text();
const data = (() => { try { return JSON.parse(text); } catch { return { message: text }; } })();
if (!response.ok) {
const error: any = new Error(data.message || 'Token exchange failed');
error.response = { data };
// Attach specific error fields if available for easier checking
error.malError = data.error;
throw error;
}
return data;
}
async login(): Promise<boolean | string> {
if (this.isAuthenticating) return 'Authentication already in progress';
this.isAuthenticating = true;
try {
console.log('[MalAuth] Starting login with redirectUri:', REDIRECT_URI);
const codeVerifier = this.generateCodeVerifier();
const state = this.generateCodeVerifier().substring(0, 20); // Simple random state
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
state: state,
code_challenge: codeVerifier,
code_challenge_method: 'plain',
redirect_uri: REDIRECT_URI,
scope: 'user_read write_share', // space separated
});
const authUrl = `${discovery.authorizationEndpoint}?${params.toString()}`;
const result = await WebBrowser.openAuthSessionAsync(authUrl, REDIRECT_URI, {
showInRecents: true,
});
console.log('[MalAuth] Auth prompt result:', result.type);
if (result.type === 'success' && result.url) {
// Parse code from URL
const urlObj = new URL(result.url);
const code = urlObj.searchParams.get('code');
const returnedState = urlObj.searchParams.get('state');
if (!code) {
return 'No authorization code received';
}
// Optional: verify state if you want strict security, though MAL state is optional
// if (returnedState !== state) console.warn('State mismatch');
console.log('[MalAuth] Success! Code received.');
try {
console.log('[MalAuth] Exchanging code for token...');
const data = await this.exchangeToken(code, codeVerifier, REDIRECT_URI);
if (data.access_token) {
console.log('[MalAuth] Token exchange successful');
this.saveToken({
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
createdAt: Date.now(),
});
return true;
}
} catch (e: any) {
// Normalize error data
const errorData = e.response?.data || (e instanceof Error ? { message: e.message, error: (e as any).malError } : e);
console.error('[MalAuth] First Token Exchange Failed:', JSON.stringify(errorData));
// Retry with trailing slash if invalid_grant
if (errorData.error === 'invalid_grant' || (errorData.message && errorData.message.includes('redirection URI'))) {
const retryUri = REDIRECT_URI + '/';
console.log(`[MalAuth] Retrying with trailing slash: '${retryUri}'`);
try {
const data = await this.exchangeToken(code, codeVerifier, retryUri);
if (data.access_token) {
console.log('[MalAuth] Retry Token exchange successful');
this.saveToken({
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
createdAt: Date.now(),
});
return true;
}
} catch (retryError: any) {
const retryErrorData = retryError.response?.data || (retryError instanceof Error ? { message: retryError.message, error: (retryError as any).malError } : retryError);
console.error('[MalAuth] Retry Token Exchange Also Failed:', JSON.stringify(retryErrorData));
return `MAL Error: ${retryErrorData.error || 'unknown'} - ${retryErrorData.message || 'No description'}`;
}
}
if (errorData) {
return `MAL Error: ${errorData.error || 'unknown'} - ${errorData.message || errorData.error_description || 'No description'}`;
}
return `Network Error: ${e.message}`;
}
} else if (result.type === 'cancel' || result.type === 'dismiss') {
return 'Login cancelled';
}
return false;
} catch (e: any) {
console.error('[MalAuth] Login Exception', e);
return `Login Exception: ${e.message}`;
} finally {
this.isAuthenticating = false;
}
}
async refreshToken(): Promise<boolean> {
const token = this.getToken();
if (!token || !token.refreshToken) return false;
try {
const body = new URLSearchParams({
client_id: CLIENT_ID,
grant_type: 'refresh_token',
refresh_token: token.refreshToken,
}).toString();
const response = await fetch(discovery.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
});
const data = await response.json();
if (data.access_token) {
this.saveToken({
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
createdAt: Date.now(),
});
return true;
}
} catch (e) {
console.error('MAL Token Refresh Error', e);
}
return false;
}
}
export const MalAuth = MalAuthService.getInstance();

317
src/services/mal/MalSync.ts Normal file
View file

@ -0,0 +1,317 @@
import { mmkvStorage } from '../mmkvStorage';
import { MalApiService } from './MalApi';
import { MalListStatus } from '../../types/mal';
import { catalogService } from '../catalogService';
import axios from 'axios';
const MAPPING_PREFIX = 'mal_map_';
export const MalSync = {
/**
* Tries to find a MAL ID using IMDb ID via MAL-Sync API.
*/
getMalIdFromImdb: async (imdbId: string): Promise<number | null> => {
if (!imdbId) return null;
// 1. Check Cache
const cacheKey = `${MAPPING_PREFIX}imdb_${imdbId}`;
const cachedId = mmkvStorage.getNumber(cacheKey);
if (cachedId) return cachedId;
// 2. Fetch from MAL-Sync API
try {
// Ensure ID format
const cleanId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`;
const response = await axios.get(`https://api.malsync.moe/mal/anime/imdb/${cleanId}`);
if (response.data && response.data.id) {
const malId = response.data.id;
// Save to cache
mmkvStorage.setNumber(cacheKey, malId);
return malId;
}
} catch (e) {
// Ignore errors (404, etc.)
}
return null;
},
/**
* Tries to find a MAL ID for a given anime title or IMDb ID.
* Caches the result to avoid repeated API calls.
*/
getMalId: async (title: string, type: 'movie' | 'series' = 'series', year?: number, season?: number, imdbId?: string): Promise<number | null> => {
// 1. Try IMDb ID first (Most accurate) - BUT only for Season 1 or Movies.
// For Season 2+, IMDb usually points to the main series (S1), while MAL has separate entries.
// So we force a search for S2+ to find the specific "Season X" entry.
if (imdbId && (type === 'movie' || !season || season === 1)) {
const idFromImdb = await MalSync.getMalIdFromImdb(imdbId);
if (idFromImdb) return idFromImdb;
}
// 2. Check Cache for Title
const cleanTitle = title.trim();
const cacheKey = `${MAPPING_PREFIX}${cleanTitle}_${type}_${season || 1}`;
const cachedId = mmkvStorage.getNumber(cacheKey);
if (cachedId) return cachedId;
// 3. Search MAL
try {
let searchQuery = cleanTitle;
// For Season 2+, explicitly search for that season
if (type === 'series' && season && season > 1) {
// Improve search query: "Attack on Titan Season 2" usually works better than just appending
searchQuery = `${cleanTitle} Season ${season}`;
}
const result = await MalApiService.searchAnime(searchQuery, 10);
if (result.data.length > 0) {
let candidates = result.data;
// Filter by type first
if (type === 'movie') {
candidates = candidates.filter(r => r.node.media_type === 'movie');
} else {
candidates = candidates.filter(r => r.node.media_type === 'tv' || r.node.media_type === 'ona' || r.node.media_type === 'special' || r.node.media_type === 'ova');
}
if (candidates.length === 0) candidates = result.data; // Fallback to all if type filtering removes everything
let bestMatch = candidates[0].node;
// If year is provided, try to find an exact start year match
if (year) {
const yearMatch = candidates.find(r => r.node.start_season?.year === year);
if (yearMatch) {
bestMatch = yearMatch.node;
} else {
// Fuzzy year match (+/- 1 year)
const fuzzyMatch = candidates.find(r => r.node.start_season?.year && Math.abs(r.node.start_season.year - year) <= 1);
if (fuzzyMatch) bestMatch = fuzzyMatch.node;
}
}
// Save to cache
mmkvStorage.setNumber(cacheKey, bestMatch.id);
return bestMatch.id;
}
} catch (e) {
console.warn('MAL Search failed for', title);
}
return null;
},
/**
* Main function to track progress
*/
scrobbleEpisode: async (
animeTitle: string,
episodeNumber: number,
totalEpisodes: number = 0,
type: 'movie' | 'series' = 'series',
season?: number,
imdbId?: string
) => {
try {
const malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId);
if (!malId) return;
let finalTotalEpisodes = totalEpisodes;
// If totalEpisodes not provided, try to fetch it from MAL details
if (finalTotalEpisodes <= 0) {
try {
const details = await MalApiService.getAnimeDetails(malId);
if (details && details.num_episodes) {
finalTotalEpisodes = details.num_episodes;
}
} catch (e) {
// Fallback to 0 if details fetch fails
}
}
// Determine Status
let status: MalListStatus = 'watching';
if (finalTotalEpisodes > 0 && episodeNumber >= finalTotalEpisodes) {
status = 'completed';
}
await MalApiService.updateStatus(malId, status, episodeNumber);
console.log(`[MalSync] Synced ${animeTitle} Ep ${episodeNumber}/${finalTotalEpisodes || '?'} -> MAL ID ${malId} (${status})`);
} catch (e) {
console.error('[MalSync] Scrobble failed:', e);
}
},
/**
* Import MAL list items into local library
*/
syncMalToLibrary: async () => {
try {
const list = await MalApiService.getUserList();
const watching = list.data.filter(item => item.list_status.status === 'watching' || item.list_status.status === 'plan_to_watch');
for (const item of watching) {
// Try to find in local catalogs to get a proper StreamingContent object
// This is complex because we need to map MAL -> Stremio/TMDB.
// For now, we'll just cache the mapping for future use.
const type = item.node.media_type === 'movie' ? 'movie' : 'series';
const cacheKey = `${MAPPING_PREFIX}${item.node.title.trim()}_${type}`;
mmkvStorage.setNumber(cacheKey, item.node.id);
}
return true;
} catch (e) {
console.error('syncMalToLibrary failed', e);
return false;
}
},
/**
* Manually map an ID if auto-detection fails
*/
setMapping: (title: string, malId: number, type: 'movie' | 'series' = 'series') => {
const cacheKey = `${MAPPING_PREFIX}${title.trim()}_${type}`;
mmkvStorage.setNumber(cacheKey, malId);
},
/**
* Get external IDs (IMDb, etc.) and season info from a MAL ID using MalSync API
*/
getIdsFromMalId: async (malId: number): Promise<{ imdbId: string | null; season: number }> => {
const cacheKey = `mal_ext_ids_v2_${malId}`;
const cached = mmkvStorage.getString(cacheKey);
if (cached) {
return JSON.parse(cached);
}
try {
const response = await axios.get(`https://api.malsync.moe/mal/anime/${malId}`);
const data = response.data;
let imdbId = null;
let season = data.season || 1;
// Try to find IMDb ID in Sites
if (data.Sites && data.Sites.IMDB) {
const imdbKeys = Object.keys(data.Sites.IMDB);
if (imdbKeys.length > 0) {
imdbId = imdbKeys[0];
}
}
const result = { imdbId, season };
mmkvStorage.setString(cacheKey, JSON.stringify(result));
return result;
} catch (e) {
console.error('[MalSync] Failed to fetch external IDs:', e);
}
return { imdbId: null, season: 1 };
},
/**
* Get weekly anime schedule from Jikan API (Adjusted to Local Timezone)
*/
getWeeklySchedule: async (): Promise<any[]> => {
const cacheKey = 'mal_weekly_schedule_local_v2'; // Bump version for new format
const cached = mmkvStorage.getString(cacheKey);
const cacheTime = mmkvStorage.getNumber(`${cacheKey}_time`);
// Cache for 24 hours
if (cached && cacheTime && (Date.now() - cacheTime < 24 * 60 * 60 * 1000)) {
return JSON.parse(cached);
}
try {
// Jikan API rate limit mitigation
await new Promise(resolve => setTimeout(resolve, 500));
const response = await axios.get('https://api.jikan.moe/v4/schedules');
const data = response.data.data;
const daysOrder = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'];
const dayMap: Record<string, number> = { 'Mondays': 0, 'Tuesdays': 1, 'Wednesdays': 2, 'Thursdays': 3, 'Fridays': 4, 'Saturdays': 5, 'Sundays': 6 };
const daysReverse = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'];
const grouped: Record<string, any[]> = {};
// Calculate time difference in minutes: Local - JST (UTC+9)
// getTimezoneOffset() returns minutes BEHIND UTC (positive for US, negative for Asia)
// We want Local - UTC+9.
// Local = UTC - offset.
// Diff = (UTC - localOffset) - (UTC + 540) = -localOffset - 540.
const jstOffset = 540; // UTC+9 in minutes
const localOffset = new Date().getTimezoneOffset(); // e.g. 300 for EST (UTC-5)
const offsetMinutes = -localOffset - jstOffset; // e.g. -300 - 540 = -840 minutes (-14h)
data.forEach((anime: any) => {
let day = anime.broadcast?.day; // "Mondays"
let time = anime.broadcast?.time; // "23:00"
let originalDay = day;
// Adjust to local time
if (day && time && dayMap[day] !== undefined) {
const [hours, mins] = time.split(':').map(Number);
let totalMinutes = hours * 60 + mins + offsetMinutes;
let dayShift = 0;
// Handle day rollovers
if (totalMinutes < 0) {
totalMinutes += 24 * 60;
dayShift = -1;
} else if (totalMinutes >= 24 * 60) {
totalMinutes -= 24 * 60;
dayShift = 1;
}
const newHour = Math.floor(totalMinutes / 60);
const newMin = totalMinutes % 60;
time = `${String(newHour).padStart(2,'0')}:${String(newMin).padStart(2,'0')}`;
let dayIndex = dayMap[day] + dayShift;
if (dayIndex < 0) dayIndex = 6;
if (dayIndex > 6) dayIndex = 0;
day = daysReverse[dayIndex];
} else {
day = 'Other'; // No specific time/day
}
if (!grouped[day]) grouped[day] = [];
grouped[day].push({
id: `mal:${anime.mal_id}`,
seriesId: `mal:${anime.mal_id}`,
title: anime.title,
seriesName: anime.title_english || anime.title,
poster: anime.images?.jpg?.large_image_url || anime.images?.jpg?.image_url,
releaseDate: null,
season: 1,
episode: 1,
overview: anime.synopsis,
vote_average: anime.score,
day: day,
time: time,
genres: anime.genres?.map((g: any) => g.name) || [],
originalDay: originalDay // Keep for debug if needed
});
});
// Sort by day (starting Monday or Today?) -> Standard is Monday start for anime
// Sort items by time within day
const result = [...daysOrder, 'Other']
.filter(day => grouped[day] && grouped[day].length > 0)
.map(day => ({
title: day,
data: grouped[day].sort((a, b) => (a.time || '99:99').localeCompare(b.time || '99:99'))
}));
mmkvStorage.setString(cacheKey, JSON.stringify(result));
mmkvStorage.setNumber(`${cacheKey}_time`, Date.now());
return result;
} catch (e) {
console.error('[MalSync] Failed to fetch schedule:', e);
return [];
}
}
};

View file

@ -0,0 +1,4 @@
export * from '../../types/mal';
export * from './MalAuth';
export * from './MalApi';
export * from './MalSync';

View file

@ -1162,33 +1162,75 @@ class LocalScraperService {
}
private async executePlugin(code: string, params: any, consoleOverride?: any): Promise<LocalScraperResult[]> {
// Execute scraper code with full access to app environment (non-sandboxed)
public async testPlugin(code: string, params: any, options?: { onLog?: (line: string) => void }): Promise<{ streams: Stream[] }> {
try {
// Create a specialized logger for testing that also calls the onLog callback
const testLogger = {
...logger,
log: (...args: any[]) => {
logger.log('[PluginTest]', ...args);
options?.onLog?.(`[LOG] ${args.join(' ')}`);
},
info: (...args: any[]) => {
logger.info('[PluginTest]', ...args);
options?.onLog?.(`[INFO] ${args.join(' ')}`);
},
warn: (...args: any[]) => {
logger.warn('[PluginTest]', ...args);
options?.onLog?.(`[WARN] ${args.join(' ')}`);
},
error: (...args: any[]) => {
logger.error('[PluginTest]', ...args);
options?.onLog?.(`[ERROR] ${args.join(' ')}`);
},
debug: (...args: any[]) => {
logger.debug('[PluginTest]', ...args);
options?.onLog?.(`[DEBUG] ${args.join(' ')}`);
}
};
const result = await this.executePluginInternal(code, params, testLogger);
// Use a dummy scraper info for the conversion
const dummyScraper: ScraperInfo = {
id: 'test-plugin',
name: 'Test Plugin',
description: 'Testing environment',
version: '1.0.0',
filename: 'test.js',
supportedTypes: ['movie', 'tv'],
enabled: true
};
const streams = this.convertToStreams(result, dummyScraper);
return { streams };
} catch (error: any) {
logger.error('[LocalScraperService] testPlugin failed:', error);
options?.onLog?.(`[FATAL] ${error.message}`);
throw error;
}
}
// Execute scraper code with full access to app environment (non-sandboxed)
private async executePlugin(code: string, params: any): Promise<any> {
return this.executePluginInternal(code, params, logger);
}
private async executePluginInternal(code: string, params: any, customLogger: any): Promise<any> {
try {
// Get URL validation setting from storage
const settingsData = await mmkvStorage.getItem('app_settings');
const settings = settingsData ? JSON.parse(settingsData) : {};
const urlValidationEnabled = settings.enableScraperUrlValidation ?? true;
// Load per-scraper settings for this run
const allScraperSettingsRaw = await mmkvStorage.getItem(this.SCRAPER_SETTINGS_KEY);
const allScraperSettings = allScraperSettingsRaw ? JSON.parse(allScraperSettingsRaw) : {};
let perScraperSettings = (params && params.scraperId && allScraperSettings[params.scraperId])
const perScraperSettings = (params && params.scraperId && allScraperSettings[params.scraperId])
? allScraperSettings[params.scraperId]
: (params?.settings || {});
if (params?.scraperId?.toLowerCase().includes('showbox')) {
const token = perScraperSettings.uiToken || perScraperSettings.cookie || perScraperSettings.token;
if (token) {
perScraperSettings = {
...perScraperSettings,
uiToken: token,
cookie: token,
token: token
};
if (params) {
params.settings = perScraperSettings;
}
}
}
// Module exports for CommonJS compatibility
const moduleExports: any = {};
const moduleObj = { exports: moduleExports };
@ -1226,63 +1268,11 @@ class LocalScraperService {
}
};
// Polyfilled fetch that properly handles redirect: 'manual'
// React Native's native fetch may or may not support redirect: 'manual' properly
const polyfilledFetch = async (url: string, options: any = {}): Promise<Response> => {
// If not using redirect: manual, use native fetch directly
if (options.redirect !== 'manual') {
return fetch(url, options);
}
// Try native fetch with redirect: 'manual' first
try {
logger.log('[PolyfilledFetch] Attempting native fetch with redirect: manual for:', url.substring(0, 50));
const nativeResponse = await fetch(url, options);
// Log what native fetch returns
const locationHeader = nativeResponse.headers.get('location');
logger.log('[PolyfilledFetch] Native fetch result - Status:', nativeResponse.status, 'URL:', nativeResponse.url?.substring(0, 60), 'Location:', locationHeader || 'none');
// Check if redirect happened - compare URLs
if (nativeResponse.url && nativeResponse.url !== url) {
// Fetch followed the redirect! Let's try to get the redirect location
// by making a HEAD request or checking if there's any pattern
logger.log('[PolyfilledFetch] REDIRECT DETECTED - Original:', url.substring(0, 50), 'Final:', nativeResponse.url.substring(0, 50));
// Create a mock 302 response with the final URL as location
const mockHeaders = new Headers(nativeResponse.headers);
mockHeaders.set('location', nativeResponse.url);
return {
ok: false,
status: 302, // Mock as 302
statusText: 'Found',
headers: mockHeaders,
url: url,
text: nativeResponse.text.bind(nativeResponse),
json: nativeResponse.json.bind(nativeResponse),
blob: nativeResponse.blob.bind(nativeResponse),
arrayBuffer: nativeResponse.arrayBuffer.bind(nativeResponse),
clone: nativeResponse.clone.bind(nativeResponse),
body: nativeResponse.body,
bodyUsed: nativeResponse.bodyUsed,
redirected: true,
type: nativeResponse.type,
formData: nativeResponse.formData.bind(nativeResponse),
} as Response;
}
return nativeResponse;
} catch (error: any) {
logger.error('[PolyfilledFetch] Native fetch error:', error.message);
throw error;
}
};
// Execution timeout (1 minute)
const PLUGIN_TIMEOUT_MS = 60000;
const functionName = params.functionName || 'getStreams';
const executionPromise = new Promise<LocalScraperResult[]>((resolve, reject) => {
const executionPromise = new Promise<any>((resolve, reject) => {
try {
// Create function with full global access
// We pass specific utilities but the plugin has access to everything
@ -1295,7 +1285,6 @@ class LocalScraperService {
'CryptoJS',
'cheerio',
'logger',
'console',
'params',
'PRIMARY_KEY',
'TMDB_API_KEY',
@ -1304,30 +1293,27 @@ class LocalScraperService {
'SCRAPER_ID',
`
// Make env vars available globally for backward compatibility
const globalScope = typeof global !== 'undefined' ? global : (typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : this));
if (globalScope) {
globalScope.PRIMARY_KEY = PRIMARY_KEY;
globalScope.TMDB_API_KEY = TMDB_API_KEY;
globalScope.SCRAPER_SETTINGS = SCRAPER_SETTINGS;
globalScope.SCRAPER_ID = SCRAPER_ID;
globalScope.URL_VALIDATION_ENABLED = URL_VALIDATION_ENABLED;
} else {
logger.error('[Plugin Sandbox] Could not find global scope to inject settings');
if (typeof global !== 'undefined') {
global.PRIMARY_KEY = PRIMARY_KEY;
global.TMDB_API_KEY = TMDB_API_KEY;
global.SCRAPER_SETTINGS = SCRAPER_SETTINGS;
global.SCRAPER_ID = SCRAPER_ID;
global.URL_VALIDATION_ENABLED = URL_VALIDATION_ENABLED;
}
// Plugin code
${code}
// Find and call getStreams function
if (typeof getStreams === 'function') {
return getStreams(params.tmdbId, params.mediaType, params.season, params.episode);
} else if (module.exports && typeof module.exports.getStreams === 'function') {
return module.exports.getStreams(params.tmdbId, params.mediaType, params.season, params.episode);
} else if (typeof global !== 'undefined' && typeof global.getStreams === 'function') {
return global.getStreams(params.tmdbId, params.mediaType, params.season, params.episode);
// Find and call target function (${functionName})
if (typeof ${functionName} === 'function') {
return ${functionName}(params.tmdbId, params.mediaType, params.season, params.episode);
} else if (module.exports && typeof module.exports.${functionName} === 'function') {
return module.exports.${functionName}(params.tmdbId, params.mediaType, params.season, params.episode);
} else if (typeof global !== 'undefined' && typeof global.${functionName} === 'function') {
return global.${functionName}(params.tmdbId, params.mediaType, params.season, params.episode);
} else {
throw new Error('No getStreams function found in plugin');
// Return null if function not found (allow optional implementation)
return null;
}
`
);
@ -1338,11 +1324,10 @@ class LocalScraperService {
moduleExports,
pluginRequire,
axios,
polyfilledFetch, // Use polyfilled fetch for redirect: manual support
fetch,
CryptoJS,
cheerio,
logger,
consoleOverride || console, // Expose console (or override) to plugins for debugging
customLogger,
params,
MOVIEBOX_PRIMARY_KEY,
MOVIEBOX_TMDB_API_KEY,
@ -1355,7 +1340,7 @@ class LocalScraperService {
if (result && typeof result.then === 'function') {
result.then(resolve).catch(reject);
} else {
resolve(result || []);
resolve(result);
}
} catch (error) {
reject(error);
@ -1371,11 +1356,80 @@ class LocalScraperService {
]);
} catch (error) {
logger.error('[LocalScraperService] Plugin execution failed:', error);
customLogger.error('[LocalScraperService] Plugin execution failed:', error);
throw error;
}
}
// Get subtitles from plugins
async getSubtitles(type: string, tmdbId: string, season?: number, episode?: number): Promise<any[]> {
await this.ensureInitialized();
// Check if local scrapers are enabled
const userSettings = await this.getUserScraperSettings();
if (!userSettings.enableLocalScrapers) {
return [];
}
// Get available scrapers from manifest (respects manifestEnabled)
const availableScrapers = await this.getAvailableScrapers();
const enabledScrapers = availableScrapers
.filter(scraper =>
scraper.enabled &&
scraper.manifestEnabled !== false
);
if (enabledScrapers.length === 0) {
return [];
}
logger.log(`[LocalScraperService] Fetching subtitles from ${enabledScrapers.length} plugins for ${type}:${tmdbId}`);
const results = await Promise.allSettled(
enabledScrapers.map(async (scraper) => {
try {
const code = this.scraperCode.get(scraper.id);
if (!code) return [];
// Load per-scraper settings
const scraperSettings = await this.getScraperSettings(scraper.id);
const subtitleResults = await this.executePlugin(code, {
tmdbId,
mediaType: type === 'series' ? 'tv' : 'movie',
season,
episode,
scraperId: scraper.id,
settings: scraperSettings,
functionName: 'getSubtitles'
});
if (Array.isArray(subtitleResults)) {
return subtitleResults.map(sub => ({
...sub,
addon: scraper.id,
addonName: scraper.name,
source: scraper.name
}));
}
return [];
} catch (e) {
// Ignore errors for individual plugins
return [];
}
})
);
const allSubtitles: any[] = [];
results.forEach(result => {
if (result.status === 'fulfilled' && Array.isArray(result.value)) {
allSubtitles.push(...result.value);
}
});
return allSubtitles;
}
// Convert scraper results to Nuvio Stream format
private convertToStreams(results: LocalScraperResult[], scraper: ScraperInfo): Stream[] {
if (!Array.isArray(results)) {
@ -1558,73 +1612,6 @@ class LocalScraperService {
}
}
// Test a plugin independently with log capturing.
// If onLog is provided, each formatted log line is emitted as it happens.
async testPlugin(
code: string,
params: { tmdbId: string; mediaType: string; season?: number; episode?: number },
options?: { onLog?: (line: string) => void }
): Promise<{ streams: Stream[]; logs: string[] }> {
const logs: string[] = [];
const emit = (line: string) => {
logs.push(line);
options?.onLog?.(line);
};
// Create a console proxy to capture logs
const consoleProxy = {
log: (...args: any[]) => {
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
emit(`[LOG] ${msg}`);
console.log('[PluginTest]', msg);
},
error: (...args: any[]) => {
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
emit(`[ERROR] ${msg}`);
console.error('[PluginTest]', msg);
},
warn: (...args: any[]) => {
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
emit(`[WARN] ${msg}`);
console.warn('[PluginTest]', msg);
},
info: (...args: any[]) => {
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
emit(`[INFO] ${msg}`);
console.info('[PluginTest]', msg);
},
debug: (...args: any[]) => {
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
emit(`[DEBUG] ${msg}`);
console.debug('[PluginTest]', msg);
}
};
try {
const results = await this.executePlugin(code, params, consoleProxy);
// Convert results using a dummy scraper info since we don't have one for ad-hoc tests
const dummyScraperInfo: ScraperInfo = {
id: 'test-plugin',
name: 'Test Plugin',
version: '1.0.0',
description: 'Test',
filename: 'test.js',
supportedTypes: ['movie', 'tv'],
enabled: true
};
const streams = this.convertToStreams(results, dummyScraperInfo);
return { streams, logs };
} catch (error: any) {
emit(`[FATAL ERROR] ${error.message || String(error)}`);
if (error.stack) {
emit(`[STACK] ${error.stack}`);
}
return { streams: [], logs };
}
}
}
export const localScraperService = LocalScraperService.getInstance();

View file

@ -5,6 +5,7 @@ import EventEmitter from 'eventemitter3';
import { localScraperService } from './pluginService';
import { DEFAULT_SETTINGS, AppSettings } from '../hooks/useSettings';
import { TMDBService } from './tmdbService';
import { MalSync } from './mal/MalSync';
// Create an event emitter for addon changes
export const addonEmitter = new EventEmitter();
@ -1185,6 +1186,64 @@ class StremioService {
async getStreams(type: string, id: string, callback?: StreamCallback): Promise<void> {
await this.ensureInitialized();
// Resolve MAL/Kitsu IDs to IMDb/TMDB for better stream compatibility
let activeId = id;
let resolvedTmdbId: string | null = null;
if (id.startsWith('mal:') || id.includes(':mal:')) {
try {
// Parse MAL ID and potential season/episode
let malId: number | null = null;
let s: number | undefined;
let e: number | undefined;
const parts = id.split(':');
// Handle mal:123
if (id.startsWith('mal:')) {
malId = parseInt(parts[1], 10);
// simple mal:id usually implies movie or main series entry, assume s1e1 if not present?
// MetadataScreen typically passes raw id for movies, or constructs episode string for series
}
// Handle series:mal:123:1:1
else if (id.includes(':mal:')) {
// series:mal:123:1:1
if (parts[1] === 'mal') malId = parseInt(parts[2], 10);
if (parts.length >= 5) {
s = parseInt(parts[3], 10);
e = parseInt(parts[4], 10);
}
}
if (malId) {
logger.log(`[getStreams] Resolving MAL ID ${malId} to IMDb/TMDB...`);
const { imdbId, season: malSeason } = await MalSync.getIdsFromMalId(malId);
if (imdbId) {
const finalSeason = s || malSeason || 1;
const finalEpisode = e || 1;
// 1. Set ID for Stremio Addons (Torrentio/Debrid searchers prefer IMDb)
if (type === 'series') {
// Ensure proper IMDb format: tt12345:1:1
activeId = `${imdbId}:${finalSeason}:${finalEpisode}`;
} else {
activeId = imdbId;
}
logger.log(`[getStreams] Resolved -> Stremio ID: ${activeId}`);
// 2. Set ID for Local Scrapers (They prefer TMDB)
const tmdbIdNum = await TMDBService.getInstance().findTMDBIdByIMDB(imdbId);
if (tmdbIdNum) {
resolvedTmdbId = tmdbIdNum.toString();
logger.log(`[getStreams] Resolved -> TMDB ID: ${resolvedTmdbId}`);
}
}
}
} catch (err) {
logger.error('[getStreams] Failed to resolve MAL ID:', err);
}
}
const addons = this.getInstalledAddons();
// Check if local scrapers are enabled and execute them first
@ -1205,7 +1264,7 @@ class StremioService {
const scraperType = type === 'series' ? 'tv' : type;
// Parse the Stremio ID to extract ID and season/episode info
let tmdbId: string | null = null;
let tmdbId: string | null = resolvedTmdbId;
let season: number | undefined = undefined;
let episode: number | undefined = undefined;
let idType: 'imdb' | 'kitsu' | 'tmdb' = 'imdb';
@ -1406,7 +1465,7 @@ class StremioService {
}
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
const encodedId = encodeURIComponent(id);
const encodedId = encodeURIComponent(activeId);
const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`;
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`);
@ -1836,8 +1895,6 @@ class StremioService {
// Check if any installed addons can provide streams (including embedded streams in metadata)
async hasStreamProviders(type?: string): Promise<boolean> {
await this.ensureInitialized();
// App-level content type "tv" maps to Stremio "series"
const normalizedType = type === 'tv' ? 'series' : type;
const addons = Array.from(this.installedAddons.values());
for (const addon of addons) {
@ -1851,12 +1908,12 @@ class StremioService {
if (hasStreamResource) {
// If type specified, also check if addon supports this type
if (normalizedType) {
const supportsType = addon.types?.includes(normalizedType) ||
if (type) {
const supportsType = addon.types?.includes(type) ||
addon.resources.some(resource =>
typeof resource === 'object' &&
(resource as any).name === 'stream' &&
(resource as any).types?.includes(normalizedType)
(resource as any).types?.includes(type)
);
if (supportsType) return true;
} else {
@ -1866,14 +1923,14 @@ class StremioService {
// Also check for addons with meta resource that support the type
// These addons might provide embedded streams within metadata
if (normalizedType) {
if (type) {
const hasMetaResource = addon.resources.some(resource =>
typeof resource === 'string'
? resource === 'meta'
: (resource as any).name === 'meta'
);
if (hasMetaResource && addon.types?.includes(normalizedType)) {
if (hasMetaResource && addon.types?.includes(type)) {
// This addon provides meta for the type - might have embedded streams
return true;
}

60
src/types/mal.ts Normal file
View file

@ -0,0 +1,60 @@
export interface MalToken {
accessToken: string;
refreshToken: string;
expiresIn: number; // Seconds
createdAt: number; // Timestamp
}
export interface MalUser {
id: number;
name: string;
picture?: string;
location?: string;
joined_at?: string;
}
export interface MalAnime {
id: number;
title: string;
main_picture?: {
medium: string;
large: string;
};
num_episodes: number;
media_type?: 'tv' | 'movie' | 'ova' | 'special' | 'ona' | 'music';
start_season?: {
year: number;
season: string;
};
}
export type MalListStatus = 'watching' | 'completed' | 'on_hold' | 'dropped' | 'plan_to_watch';
export interface MalMyListStatus {
status: MalListStatus;
score: number;
num_episodes_watched: number;
is_rewatching: boolean;
updated_at: string;
}
export interface MalAnimeNode {
node: MalAnime;
list_status: MalMyListStatus;
}
export interface MalUserListResponse {
data: MalAnimeNode[];
paging: {
next?: string;
previous?: string;
};
}
export interface MalSearchResult {
data: MalAnimeNode[];
paging: {
next?: string;
previous?: string;
};
}