mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-22 18:47:44 +00:00
feat: restore project state to commit 696a2379 and integrate new MappingService
This commit is contained in:
parent
a8867df4e6
commit
744f79a264
36 changed files with 136144 additions and 373 deletions
45
.github/workflows/android-debug.yml
vendored
Normal file
45
.github/workflows/android-debug.yml
vendored
Normal 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
|
||||
65
.github/workflows/release.yml
vendored
65
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
2
App.tsx
2
App.tsx
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
maven { url 'https://plugins.gradle.org/m2/' }
|
||||
}
|
||||
|
||||
def reactNativeGradlePlugin = new File(
|
||||
providers.exec {
|
||||
workingDir(rootDir)
|
||||
|
|
|
|||
BIN
assets/rating-icons/mal-icon.png
Normal file
BIN
assets/rating-icons/mal-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
132969
src/assets/mappings.json
Normal file
File diff suppressed because it is too large
Load diff
148
src/components/WebViewExtractor.tsx
Normal file
148
src/components/WebViewExtractor.tsx
Normal 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
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
260
src/components/metadata/MalScoreModal.tsx
Normal file
260
src/components/metadata/MalScoreModal.tsx
Normal 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 },
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
356
src/screens/MalSettingsScreen.tsx
Normal file
356
src/screens/MalSettingsScreen.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
219
src/services/MappingService.ts
Normal file
219
src/services/MappingService.ts
Normal 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();
|
||||
91
src/services/StreamExtractorService.ts
Normal file
91
src/services/StreamExtractorService.ts
Normal 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;
|
||||
83
src/services/anilist/AniListService.ts
Normal file
83
src/services/anilist/AniListService.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
44
src/services/anilist/types.ts
Normal file
44
src/services/anilist/types.ts
Normal 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
113
src/services/mal/MalApi.ts
Normal 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
256
src/services/mal/MalAuth.ts
Normal 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
317
src/services/mal/MalSync.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
4
src/services/mal/index.ts
Normal file
4
src/services/mal/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from '../../types/mal';
|
||||
export * from './MalAuth';
|
||||
export * from './MalApi';
|
||||
export * from './MalSync';
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
60
src/types/mal.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue