Merge pull request #419 from paregi12/Mal

Mal Authentication service
This commit is contained in:
Nayif 2026-03-13 05:52:27 +05:30 committed by GitHub
commit 1367972681
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 4085 additions and 880 deletions

View file

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

View file

@ -12,6 +12,9 @@
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
# Enable Gradle Build Cache
org.gradle.caching=true
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -110,21 +110,23 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
}
fun setPlayer(player: ExoPlayer?) {
val currentPlayer = playerView.player
if (currentPlayer != null) {
currentPlayer.removeListener(playerListener)
}
playerView.player = player
player?.addListener(playerListener)
}
if (player != null) {
player.addListener(playerListener)
// Apply pending resize mode if we have one
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
fun setResizeMode(@ResizeMode.Mode mode: Int) {
val resizeMode = when (mode) {
ResizeMode.RESIZE_MODE_FIT -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
ResizeMode.RESIZE_MODE_FIXED_WIDTH -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
ResizeMode.RESIZE_MODE_FILL -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FILL
ResizeMode.RESIZE_MODE_ZOOM -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
if (playerView.width > 0 && playerView.height > 0) {
playerView.resizeMode = resizeMode
} else {
pendingResizeMode = resizeMode
}
// Re-assert subtitle rendering mode for the current style.
@ -134,27 +136,34 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
fun getPlayerView(): PlayerView = playerView
fun setResizeMode(@ResizeMode.Mode resizeMode: Int) {
val targetResizeMode = when (resizeMode) {
ResizeMode.RESIZE_MODE_FILL -> AspectRatioFrameLayout.RESIZE_MODE_FILL
ResizeMode.RESIZE_MODE_CENTER_CROP -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
ResizeMode.RESIZE_MODE_FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
ResizeMode.RESIZE_MODE_FIXED_WIDTH -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
else -> AspectRatioFrameLayout.RESIZE_MODE_FIT
}
fun setShowSubtitleButton(show: Boolean) {
playerView.setShowSubtitleButton(show)
}
// Apply the resize mode to PlayerView immediately
playerView.resizeMode = targetResizeMode
fun setUseController(useController: Boolean) {
playerView.useController = useController
}
// Store it for reapplication if needed
pendingResizeMode = targetResizeMode
fun setControllerHideOnTouch(hideOnTouch: Boolean) {
playerView.setControllerHideOnTouch(hideOnTouch)
}
// Force PlayerView to recalculate its layout
playerView.requestLayout()
fun setControllerAutoShow(autoShow: Boolean) {
playerView.setControllerAutoShow(autoShow)
}
// Also request layout on the parent to ensure proper sizing
requestLayout()
fun setControllerShowTimeoutMs(timeoutMs: Int) {
playerView.controllerShowTimeoutMs = timeoutMs
}
fun isControllerVisible(): Boolean = playerView.isControllerFullyVisible
fun hideController() {
playerView.hideController()
}
fun showController() {
playerView.showController()
}
fun setSubtitleStyle(style: SubtitleStyle) {
@ -281,89 +290,16 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
playerView.setShutterBackgroundColor(color)
}
fun updateSurfaceView(viewType: Int) {
// TODO: Implement proper surface type switching if needed
fun setShowLiveBadge(show: Boolean) {
liveBadge.visibility = if (show) View.VISIBLE else View.GONE
}
val isPlaying: Boolean
get() = playerView.player?.isPlaying ?: false
fun invalidateAspectRatio() {
// PlayerView handles aspect ratio automatically through its internal AspectRatioFrameLayout
playerView.requestLayout()
// Reapply the current resize mode to ensure it's properly set
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
playerView.post {
playerView.requestLayout()
}
}
fun setUseController(useController: Boolean) {
playerView.useController = useController
if (useController) {
// Ensure proper touch handling when controls are enabled
playerView.controllerAutoShow = true
playerView.controllerHideOnTouch = true
// Show controls immediately when enabled
playerView.showController()
}
}
fun showController() {
playerView.showController()
}
fun hideController() {
playerView.hideController()
}
fun setControllerShowTimeoutMs(showTimeoutMs: Int) {
playerView.controllerShowTimeoutMs = showTimeoutMs
}
fun setControllerAutoShow(autoShow: Boolean) {
playerView.controllerAutoShow = autoShow
}
fun setControllerHideOnTouch(hideOnTouch: Boolean) {
playerView.controllerHideOnTouch = hideOnTouch
}
fun setFullscreenButtonClickListener(listener: PlayerView.FullscreenButtonClickListener?) {
playerView.setFullscreenButtonClickListener(listener)
}
fun setShowSubtitleButton(show: Boolean) {
playerView.setShowSubtitleButton(show)
}
fun isControllerVisible(): Boolean = playerView.isControllerFullyVisible
fun setControllerVisibilityListener(listener: PlayerView.ControllerVisibilityListener?) {
playerView.setControllerVisibilityListener(listener)
}
override fun addOnLayoutChangeListener(listener: View.OnLayoutChangeListener) {
playerView.addOnLayoutChangeListener(listener)
}
override fun setFocusable(focusable: Boolean) {
playerView.isFocusable = focusable
}
private fun updateLiveUi() {
val player = playerView.player ?: return
val isLive = player.isCurrentMediaItemLive
val seekable = player.isCurrentMediaItemSeekable
// Show/hide badge
liveBadge.visibility = if (isLive) View.VISIBLE else View.GONE
// Disable/enable scrubbing based on seekable
val timeBar = playerView.findViewById<DefaultTimeBar?>(androidx.media3.ui.R.id.exo_progress)
timeBar?.isEnabled = !isLive || seekable
}
private val playerListener = object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
// Keep overlay subtitles in sync. This does NOT interfere with PlayerView's own subtitle rendering.
@ -375,61 +311,15 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
playerView.post {
playerView.requestLayout()
// Reapply resize mode to ensure it's properly set after timeline changes
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
}
updateLiveUi()
}
override fun onEvents(player: Player, events: Player.Events) {
if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) ||
events.contains(Player.EVENT_IS_PLAYING_CHANGED)
) {
updateLiveUi()
}
// Handle video size changes which affect aspect ratio
if (events.contains(Player.EVENT_VIDEO_SIZE_CHANGED)) {
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
playerView.requestLayout()
requestLayout()
}
}
}
companion object {
private const val TAG = "ExoPlayerView"
}
/**
* React Native (Yoga) can sometimes defer layout passes that are required by
* PlayerView for its child views (controller overlay, surface view, subtitle view, ).
* This helper forces a second measure / layout after RN finishes, ensuring the
* internal views receive the final size. The same approach is used in the v7
* implementation (see VideoView.kt) and in React Native core (Toolbar example [link]).
*/
private val layoutRunnable = Runnable {
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
)
layout(left, top, right, bottom)
}
override fun requestLayout() {
super.requestLayout()
// Post a second layout pass so the ExoPlayer internal views get correct bounds.
post(layoutRunnable)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
if (changed) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
if (width > 0 && height > 0) {
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}

View file

@ -13,17 +13,12 @@ import { useTranslation } from 'react-i18next';
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns';
import Animated, { FadeIn } from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
import { CalendarEpisode } from '../../types/calendar';
const { width } = Dimensions.get('window');
const COLUMN_COUNT = 7; // 7 days in a week
const DAY_ITEM_SIZE = (width - 32 - 56) / 7; // Slightly smaller than 1/7 to fit all days
interface CalendarEpisode {
id: string;
releaseDate: string;
// Other properties can be included but aren't needed for the calendar
}
interface DayItemProps {
date: Date;
isCurrentMonth: boolean;
@ -45,8 +40,7 @@ const DayItem = ({
isSelected,
hasEvents,
onPress
}: DayItemProps) => {
const { currentTheme } = useTheme();
}: DayItemProps) => { const { currentTheme } = useTheme();
return (
<TouchableOpacity
style={[

View file

@ -23,6 +23,7 @@ import Animated, { FadeIn, Layout } from 'react-native-reanimated';
import { useCalendarData } from '../../hooks/useCalendarData';
import { memoryManager } from '../../utils/memoryManager';
import { tmdbService } from '../../services/tmdbService';
import { CalendarEpisode } from '../../types/calendar';
// Compute base sizes; actual tablet sizes will be adjusted inside component for responsiveness
const { width } = Dimensions.get('window');
@ -43,7 +44,7 @@ interface ThisWeekEpisode {
seriesName: string;
title: string;
poster: string;
releaseDate: string;
releaseDate: string | null;
season: number;
episode: number;
isReleased: boolean;

View file

@ -0,0 +1,350 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TextInput,
ScrollView,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Alert,
Switch,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
import { MalApiService } from '../../services/mal/MalApi';
import { MalListStatus, MalAnimeNode } from '../../types/mal';
import { useToast } from '../../contexts/ToastContext';
interface MalEditModalProps {
visible: boolean;
onClose: () => void;
anime: MalAnimeNode;
onUpdateSuccess: () => void;
}
export const MalEditModal: React.FC<MalEditModalProps> = ({
visible,
onClose,
anime,
onUpdateSuccess,
}) => {
const { currentTheme } = useTheme();
const { showSuccess, showError } = useToast();
const [status, setStatus] = useState<MalListStatus>(anime.list_status.status);
const [episodes, setEpisodes] = useState(anime.list_status.num_episodes_watched.toString());
const [score, setScore] = useState(anime.list_status.score.toString());
const [isRewatching, setIsRewatching] = useState(anime.list_status.is_rewatching || false);
const [isUpdating, setIsUpdating] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
useEffect(() => {
if (visible) {
setStatus(anime.list_status.status);
setEpisodes(anime.list_status.num_episodes_watched.toString());
setScore(anime.list_status.score.toString());
setIsRewatching(anime.list_status.is_rewatching || false);
}
}, [visible, anime]);
const handleUpdate = async () => {
setIsUpdating(true);
try {
const epNum = parseInt(episodes, 10) || 0;
let scoreNum = parseInt(score, 10) || 0;
// Validation: MAL scores must be between 0 and 10
scoreNum = Math.max(0, Math.min(10, scoreNum));
await MalApiService.updateStatus(anime.node.id, status, epNum, scoreNum, isRewatching);
showSuccess('Updated', `${anime.node.title} status updated on MAL`);
onUpdateSuccess();
onClose();
} catch (error) {
showError('Update Failed', 'Could not update MAL status');
} finally {
setIsUpdating(false);
}
};
const handleRemove = async () => {
Alert.alert(
'Remove from List',
`Are you sure you want to remove ${anime.node.title} from your MyAnimeList?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: async () => {
setIsRemoving(true);
try {
await MalApiService.removeFromList(anime.node.id);
showSuccess('Removed', `${anime.node.title} removed from MAL`);
onUpdateSuccess();
onClose();
} catch (error) {
showError('Remove Failed', 'Could not remove from MAL');
} finally {
setIsRemoving(false);
}
}
}
]
);
};
const statusOptions: { 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' },
];
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
<View style={[styles.modalContent, { backgroundColor: currentTheme.colors.darkGray || '#1A1A1A' }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1}>
Edit {anime.node.title}
</Text>
<TouchableOpacity onPress={onClose}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.mediumEmphasis} />
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>Status</Text>
<View style={styles.statusGrid}>
{statusOptions.map((option) => (
<TouchableOpacity
key={option.value}
style={[
styles.statusChip,
{ borderColor: currentTheme.colors.border },
status === option.value && {
backgroundColor: currentTheme.colors.primary,
borderColor: currentTheme.colors.primary
}
]}
onPress={() => setStatus(option.value)}
>
<Text style={[
styles.statusText,
{ color: currentTheme.colors.highEmphasis },
status === option.value && { color: 'white' }
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
<View style={styles.inputRow}>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>
Episodes ({anime.node.num_episodes || '?'})
</Text>
<TextInput
style={[styles.input, {
color: currentTheme.colors.highEmphasis,
borderColor: currentTheme.colors.border,
backgroundColor: currentTheme.colors.elevation1
}]}
value={episodes}
onChangeText={setEpisodes}
keyboardType="numeric"
placeholder="0"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>Score (0-10)</Text>
<TextInput
style={[styles.input, {
color: currentTheme.colors.highEmphasis,
borderColor: currentTheme.colors.border,
backgroundColor: currentTheme.colors.elevation1
}]}
value={score}
onChangeText={setScore}
keyboardType="numeric"
placeholder="0"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
/>
</View>
</View>
<View style={styles.rewatchRow}>
<View style={styles.rewatchTextContainer}>
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis, marginTop: 0 }]}>Rewatching</Text>
<Text style={[styles.rewatchDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Mark this if you are watching the series again.
</Text>
</View>
<Switch
value={isRewatching}
onValueChange={setIsRewatching}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={isRewatching ? currentTheme.colors.primary : '#f4f3f4'}
/>
</View>
<TouchableOpacity
style={[styles.updateButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleUpdate}
disabled={isUpdating || isRemoving}
>
{isUpdating ? (
<ActivityIndicator color="white" />
) : (
<Text style={styles.updateButtonText}>Update MAL</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[styles.removeButton, { borderColor: currentTheme.colors.error || '#FF5252' }]}
onPress={handleRemove}
disabled={isUpdating || isRemoving}
>
{isRemoving ? (
<ActivityIndicator color={currentTheme.colors.error || '#FF5252'} />
) : (
<Text style={[styles.removeButtonText, { color: currentTheme.colors.error || '#FF5252' }]}>
Remove from List
</Text>
)}
</TouchableOpacity>
</ScrollView>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
container: {
width: '100%',
maxWidth: 400,
},
modalContent: {
borderRadius: 16,
padding: 20,
maxHeight: '90%',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 18,
fontWeight: '700',
flex: 1,
marginRight: 10,
},
label: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
marginTop: 12,
},
statusGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
statusChip: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 20,
borderWidth: 1,
marginBottom: 4,
},
statusText: {
fontSize: 13,
fontWeight: '500',
},
inputRow: {
flexDirection: 'row',
gap: 16,
marginTop: 8,
},
inputGroup: {
flex: 1,
},
input: {
height: 44,
borderRadius: 8,
borderWidth: 1,
paddingHorizontal: 12,
fontSize: 16,
},
rewatchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 20,
paddingVertical: 8,
},
rewatchTextContainer: {
flex: 1,
marginRight: 16,
},
rewatchDescription: {
fontSize: 12,
marginTop: 2,
},
updateButton: {
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginTop: 24,
marginBottom: 10,
},
updateButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '700',
},
removeButton: {
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginTop: 8,
marginBottom: 20,
borderWidth: 1,
},
removeButtonText: {
fontSize: 16,
fontWeight: '600',
},
});

View file

@ -335,7 +335,7 @@ 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';
@ -1882,25 +1882,25 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
{/* Optimized Action Buttons */}
<ActionButtons
handleShowStreams={handleShowStreams}
toggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
type={type}
id={id}
navigation={navigation}
playButtonText={playButtonText}
animatedStyle={buttonsAnimatedStyle}
isWatched={isWatched}
watchProgress={watchProgress}
groupedEpisodes={groupedEpisodes}
metadata={metadata}
settings={settings}
// Trakt integration props
isAuthenticated={isAuthenticated}
isInWatchlist={isInWatchlist}
isInCollection={isInCollection}
onToggleWatchlist={onToggleWatchlist}
onToggleCollection={onToggleCollection}
/>
toggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
type={type}
id={id}
navigation={navigation}
playButtonText={playButtonText}
animatedStyle={buttonsAnimatedStyle}
isWatched={isWatched}
watchProgress={watchProgress}
groupedEpisodes={groupedEpisodes}
metadata={metadata}
settings={settings}
// Trakt integration props
isAuthenticated={isAuthenticated}
isInWatchlist={isInWatchlist}
isInCollection={isInCollection}
onToggleWatchlist={onToggleWatchlist}
onToggleCollection={onToggleCollection}
/>
</View>
</LinearGradient>
</Animated.View>

View file

@ -17,8 +17,9 @@ import { TraktService } from '../../services/traktService';
import { watchedService } from '../../services/watchedService';
import { logger } from '../../utils/logger';
import { mmkvStorage } from '../../services/mmkvStorage';
import { MalSync } from '../../services/mal/MalSync';
// Enhanced responsive breakpoints for Seasons Section
// ... other imports
const BREAKPOINTS = {
phone: 0,
tablet: 768,
@ -33,7 +34,15 @@ 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;
mal_id?: number;
external_ids?: {
mal_id?: number;
}
};
imdbId?: string; // IMDb ID for Trakt sync
}
@ -573,12 +582,31 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
// 3. Background Async Operation
const showImdbId = imdbId || metadata.id;
const malId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id;
const tmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id;
// Calculate dayIndex for same-day releases
let dayIndex = 0;
if (episode.air_date) {
const sameDayEpisodes = episodes
.filter(ep => ep.air_date === episode.air_date)
.sort((a, b) => a.episode_number - b.episode_number);
dayIndex = sameDayEpisodes.findIndex(ep => ep.episode_number === episode.episode_number);
if (dayIndex < 0) dayIndex = 0;
}
try {
const result = await watchedService.markEpisodeAsWatched(
showImdbId,
metadata.id,
showImdbId || 'Anime',
metadata.id || '',
episode.season_number,
episode.episode_number
episode.episode_number,
new Date(),
episode.air_date,
metadata?.name,
malId,
dayIndex,
tmdbId
);
// Reload to ensure consistency (e.g. if optimistic update was slightly off or for other effects)
@ -664,6 +692,24 @@ 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 lastEpisodeData = seasonEpisodes.find(e => e.episode_number === lastEp);
const totalEpisodes = Object.values(groupedEpisodes).reduce((acc, curr) => acc + (curr?.length || 0), 0);
MalSync.scrobbleEpisode(
metadata.name,
lastEp,
totalEpisodes,
'series',
currentSeason,
imdbId,
lastEpisodeData?.air_date // Pass release date for accuracy
);
}
// Re-sync with source of truth
loadEpisodesProgress();
@ -2349,4 +2395,4 @@ const styles = StyleSheet.create({
fontWeight: '800',
},
});
});

View file

@ -55,9 +55,11 @@ 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 { buildExoAudioTrackName, buildExoSubtitleTrackName } from './android/components/VideoSurface';
@ -75,7 +77,7 @@ const AndroidVideoPlayer: React.FC = () => {
const {
uri, title = 'Episode Name', season, episode, episodeTitle, quality, year,
streamProvider, streamName, headers, id, type, episodeId, imdbId,
availableStreams: passedAvailableStreams, backdrop, groupedEpisodes
availableStreams: passedAvailableStreams, backdrop, groupedEpisodes, releaseDate
} = route.params;
// --- State & Custom Hooks ---
@ -219,6 +221,21 @@ const AndroidVideoPlayer: React.FC = () => {
episodeId: episodeId
});
const currentMalId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id;
const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id;
// Calculate dayIndex for same-day releases
const currentDayIndex = useMemo(() => {
if (!releaseDate || !groupedEpisodes) return 0;
// Flatten groupedEpisodes to search for same-day releases
const allEpisodes = Object.values(groupedEpisodes).flat();
const sameDayEpisodes = allEpisodes
.filter(ep => ep.air_date === releaseDate)
.sort((a, b) => a.episode_number - b.episode_number);
const idx = sameDayEpisodes.findIndex(ep => ep.episode_number === episode);
return idx >= 0 ? idx : 0;
}, [releaseDate, groupedEpisodes, episode]);
const watchProgress = useWatchProgress(
id, type, episodeId,
playerState.currentTime,
@ -227,7 +244,15 @@ const AndroidVideoPlayer: React.FC = () => {
traktAutosync,
controlsHook.seekToTime,
currentStreamProvider,
isInPictureInPicture || isPiPTransitionPending
imdbId,
season,
episode,
releaseDate,
currentMalId,
currentDayIndex,
currentTmdbId,
isInPictureInPicture || isPiPTransitionPending,
metadata?.name
);
const gestureControls = usePlayerGestureControls({
@ -717,41 +742,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;
@ -770,7 +839,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
@ -1091,6 +1160,7 @@ const AndroidVideoPlayer: React.FC = () => {
episode={episode}
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
releaseDate={releaseDate}
skipIntervals={skipIntervals}
currentTime={playerState.currentTime}
onSkip={(endTime) => controlsHook.seekToTime(endTime)}

View file

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { View, StatusBar, StyleSheet, Animated, Dimensions, ActivityIndicator } from 'react-native';
import { useNavigation, useRoute } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@ -53,8 +53,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';
@ -78,6 +80,7 @@ interface PlayerRouteParams {
backdrop?: string;
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
headers?: Record<string, string>;
releaseDate?: string;
initialPosition?: number;
}
@ -92,7 +95,7 @@ const KSPlayerCore: React.FC = () => {
const {
uri, title, episodeTitle, season, episode, id, type, quality, year,
episodeId, imdbId, backdrop, availableStreams,
headers, streamProvider, streamName,
headers, streamProvider, streamName, releaseDate,
initialPosition: routeInitialPosition
} = params;
@ -239,13 +242,38 @@ const KSPlayerCore: React.FC = () => {
}
});
const currentMalId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id;
const currentTmdbId = (metadata as any)?.tmdbId || (metadata as any)?.external_ids?.tmdb_id;
// Calculate dayIndex for same-day releases
const currentDayIndex = useMemo(() => {
if (!releaseDate || !groupedEpisodes) return 0;
// Flatten groupedEpisodes to search for same-day releases
const allEpisodes = Object.values(groupedEpisodes).flat() as any[];
const sameDayEpisodes = allEpisodes
.filter(ep => ep.air_date === releaseDate)
.sort((a, b) => a.episode_number - b.episode_number);
const idx = sameDayEpisodes.findIndex(ep => ep.episode_number === episode);
return idx >= 0 ? idx : 0;
}, [releaseDate, groupedEpisodes, episode]);
const watchProgress = useWatchProgress(
id, type, episodeId,
currentTime,
duration,
paused,
traktAutosync,
controls.seekToTime
controls.seekToTime,
undefined,
imdbId,
season,
episode,
releaseDate,
currentMalId,
currentDayIndex,
currentTmdbId,
false, // KSPlayer doesn't support PiP yet
metadata?.name
);
// Gestures
@ -334,32 +362,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);
}
@ -377,7 +453,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
@ -965,6 +1042,7 @@ const KSPlayerCore: React.FC = () => {
episode={episode}
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
releaseDate={releaseDate}
skipIntervals={skipIntervals}
currentTime={currentTime}
onSkip={(endTime) => controls.seekToTime(endTime)}

View file

@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react';
import { StatusBar, Platform, Dimensions, AppState } from 'react-native';
import RNImmersiveMode from 'react-native-immersive-mode';
import * as NavigationBar from 'expo-navigation-bar';
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
@ -8,6 +7,16 @@ import { logger } from '../../../../utils/logger';
import { useFocusEffect } from '@react-navigation/native';
import { useCallback } from 'react';
// Optional Android immersive mode module
let RNImmersiveMode: any = null;
if (Platform.OS === 'android') {
try {
RNImmersiveMode = require('react-native-immersive-mode').default;
} catch {
RNImmersiveMode = null;
}
}
const DEBUG_MODE = false;
export const usePlayerSetup = (
@ -35,8 +44,14 @@ export const usePlayerSetup = (
const enableImmersiveMode = async () => {
if (Platform.OS === 'android') {
// Standard immersive mode
RNImmersiveMode.setBarTranslucent(true);
RNImmersiveMode.fullLayout(true);
if (RNImmersiveMode) {
try {
RNImmersiveMode.setBarTranslucent(true);
RNImmersiveMode.fullLayout(true);
} catch (e) {
console.warn('[usePlayerSetup] RNImmersiveMode failed:', e);
}
}
StatusBar.setHidden(true, 'none');
// Explicitly hide bottom navigation bar using Expo
@ -51,8 +66,12 @@ export const usePlayerSetup = (
const disableImmersiveMode = async () => {
if (Platform.OS === 'android') {
RNImmersiveMode.setBarTranslucent(false);
RNImmersiveMode.fullLayout(false);
if (RNImmersiveMode) {
try {
RNImmersiveMode.setBarTranslucent(false);
RNImmersiveMode.fullLayout(false);
} catch (e) { }
}
StatusBar.setHidden(false, 'fade');
try {

View file

@ -9,6 +9,7 @@ interface UseSkipSegmentsProps {
episode?: number;
malId?: string;
kitsuId?: string;
releaseDate?: string;
enabled: boolean;
}
@ -19,6 +20,7 @@ export const useSkipSegments = ({
episode,
malId,
kitsuId,
releaseDate,
enabled
}: UseSkipSegmentsProps) => {
const [segments, setSegments] = useState<SkipInterval[]>([]);
@ -27,7 +29,7 @@ export const useSkipSegments = ({
const lastKeyRef = useRef('');
useEffect(() => {
const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`;
const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${releaseDate}`;
if (!enabled || type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) {
setSegments([]);
@ -53,7 +55,7 @@ export const useSkipSegments = ({
const fetchSegments = async () => {
try {
const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId);
const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, releaseDate);
// Ignore stale responses from old requests.
if (cancelled || lastKeyRef.current !== key) return;
@ -76,7 +78,7 @@ export const useSkipSegments = ({
return () => {
cancelled = true;
};
}, [imdbId, type, season, episode, malId, kitsuId, enabled]);
}, [imdbId, type, season, episode, malId, kitsuId, releaseDate, enabled]);
const getActiveSegment = (currentTime: number) => {
return segments.find(

View file

@ -3,6 +3,7 @@ import { AppState } from 'react-native';
import { storageService } from '../../../services/storageService';
import { logger } from '../../../utils/logger';
import { useSettings } from '../../../hooks/useSettings';
import { watchedService } from '../../../services/watchedService';
export const useWatchProgress = (
id: string | undefined,
@ -14,7 +15,16 @@ export const useWatchProgress = (
traktAutosync: any,
seekToTime: (time: number) => void,
addonId?: string,
isInPictureInPicture: boolean = false
// New parameters for MAL scrobbling
imdbId?: string,
season?: number,
episode?: number,
releaseDate?: string,
malId?: number,
dayIndex?: number,
tmdbId?: number,
isInPictureInPicture: boolean = false,
title?: string
) => {
const [resumePosition, setResumePosition] = useState<number | null>(null);
const [savedDuration, setSavedDuration] = useState<number | null>(null);
@ -22,12 +32,40 @@ export const useWatchProgress = (
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
const { settings: appSettings } = useSettings();
const initialSeekTargetRef = useRef<number | null>(null);
const hasScrobbledRef = useRef(false);
const wasPausedRef = useRef<boolean>(paused);
const [progressSaveInterval, setProgressSaveInterval] = useState<NodeJS.Timeout | null>(null);
// Values refs for unmount cleanup
// Values refs for unmount cleanup and stale closure prevention
const currentTimeRef = useRef(currentTime);
const durationRef = useRef(duration);
const imdbIdRef = useRef(imdbId);
const seasonRef = useRef(season);
const episodeRef = useRef(episode);
const releaseDateRef = useRef(releaseDate);
const malIdRef = useRef(malId);
const dayIndexRef = useRef(dayIndex);
const tmdbIdRef = useRef(tmdbId);
const isInPictureInPictureRef = useRef(isInPictureInPicture);
const titleRef = useRef(title);
// Sync refs
useEffect(() => {
imdbIdRef.current = imdbId;
seasonRef.current = season;
episodeRef.current = episode;
releaseDateRef.current = releaseDate;
malIdRef.current = malId;
dayIndexRef.current = dayIndex;
tmdbIdRef.current = tmdbId;
isInPictureInPictureRef.current = isInPictureInPicture;
titleRef.current = title;
}, [imdbId, season, episode, releaseDate, malId, dayIndex, tmdbId, isInPictureInPicture, title]);
// Reset scrobble flag when content changes
useEffect(() => {
hasScrobbledRef.current = false;
}, [id, episodeId]);
useEffect(() => {
currentTimeRef.current = currentTime;
@ -37,10 +75,6 @@ export const useWatchProgress = (
durationRef.current = duration;
}, [duration]);
useEffect(() => {
isInPictureInPictureRef.current = isInPictureInPicture;
}, [isInPictureInPicture]);
// Keep latest traktAutosync ref to avoid dependency cycles in listeners
const traktAutosyncRef = useRef(traktAutosync);
useEffect(() => {
@ -129,6 +163,39 @@ export const useWatchProgress = (
try {
await storageService.setWatchProgress(id, type, progress, episodeId);
await traktAutosync.handleProgressUpdate(currentTimeRef.current, durationRef.current);
// Requirement 1: Auto Episode Tracking (>= 90% completion)
const progressPercent = (currentTimeRef.current / durationRef.current) * 100;
if (progressPercent >= 90 && !hasScrobbledRef.current) {
hasScrobbledRef.current = true;
logger.log(`[useWatchProgress] 90% threshold reached, scrobbling to MAL...`);
const currentImdbId = imdbIdRef.current;
const currentSeason = seasonRef.current;
const currentEpisode = episodeRef.current;
const currentReleaseDate = releaseDateRef.current;
const currentMalId = malIdRef.current;
const currentDayIndex = dayIndexRef.current;
const currentTmdbId = tmdbIdRef.current;
const currentTitle = titleRef.current;
if (type === 'series' && currentImdbId && currentSeason !== undefined && currentEpisode !== undefined) {
watchedService.markEpisodeAsWatched(
currentImdbId,
id,
currentSeason,
currentEpisode,
new Date(),
currentReleaseDate,
undefined,
currentMalId,
currentDayIndex,
currentTmdbId
);
} else if (type === 'movie' && currentImdbId) {
watchedService.markMovieAsWatched(currentImdbId, new Date(), currentMalId, currentTmdbId, currentTitle);
}
}
} catch (error) {
logger.error('[useWatchProgress] Error saving watch progress:', error);
}
@ -137,6 +204,7 @@ export const useWatchProgress = (
useEffect(() => {
// Handle pause transitions (upstream)
if (wasPausedRef.current !== paused) {
const becamePaused = paused;
wasPausedRef.current = paused;
@ -144,7 +212,23 @@ export const useWatchProgress = (
void saveWatchProgress();
}
}
}, [paused]);
// Handle periodic save when playing (MAL branch)
if (id && type && !paused) {
if (progressSaveInterval) clearInterval(progressSaveInterval);
// Use refs inside the interval so we don't need to restart it on every second
const interval = setInterval(() => {
saveWatchProgress();
}, 10000);
setProgressSaveInterval(interval);
return () => {
clearInterval(interval);
setProgressSaveInterval(null);
};
}
}, [id, type, paused]);
// Unmount Save - deferred to allow navigation to complete first
useEffect(() => {

View file

@ -23,6 +23,7 @@ interface SkipIntroButtonProps {
episode?: number;
malId?: string;
kitsuId?: string;
releaseDate?: string;
skipIntervals?: SkipInterval[] | null;
currentTime: number;
onSkip: (endTime: number) => void;
@ -37,6 +38,7 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
episode,
malId,
kitsuId,
releaseDate,
skipIntervals: externalSkipIntervals,
currentTime,
onSkip,
@ -56,6 +58,7 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
episode,
malId,
kitsuId,
releaseDate,
// Allow parent components to provide pre-fetched intervals to avoid duplicate requests.
enabled: skipIntroEnabled && !externalSkipIntervals
});
@ -79,7 +82,7 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
useEffect(() => {
setHasSkippedCurrent(false);
setAutoHidden(false);
}, [imdbId, season, episode, malId, kitsuId]);
}, [imdbId, season, episode, malId, kitsuId, releaseDate]);
// Determine active interval based on current playback position
useEffect(() => {

View file

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

View file

@ -8,27 +8,7 @@ import { logger } from '../utils/logger';
import { memoryManager } from '../utils/memoryManager';
import { parseISO, isBefore, isAfter, startOfToday, addWeeks, isThisWeek } from 'date-fns';
import { StreamingContent } from '../services/catalogService';
interface CalendarEpisode {
id: string;
seriesId: string;
title: string;
seriesName: string;
poster: string;
releaseDate: string;
season: number;
episode: number;
overview: string;
vote_average: number;
still_path: string | null;
season_poster_path: string | null;
addonId?: string;
}
interface CalendarSection {
title: string;
data: CalendarEpisode[];
}
import { CalendarEpisode, CalendarSection } from '../types/calendar';
interface UseCalendarDataReturn {
calendarData: CalendarSection[];
@ -334,6 +314,8 @@ export const useCalendarData = (): UseCalendarDataReturn => {
// Sort episodes by release date with error handling
allEpisodes.sort((a, b) => {
try {
if (!a.releaseDate) return 1;
if (!b.releaseDate) return -1;
const dateA = new Date(a.releaseDate).getTime();
const dateB = new Date(b.releaseDate).getTime();
return dateA - dateB;

View file

@ -13,6 +13,7 @@ import { mmkvStorage } from '../services/mmkvStorage';
import { Stream } from '../types/metadata';
import { storageService } from '../services/storageService';
import { useSettings } from './useSettings';
import { MalSync } from '../services/mal/MalSync';
// Constants for timeouts and retries
const API_TIMEOUT = 10000; // 10 seconds
@ -488,6 +489,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const loadMetadata = async () => {
try {
console.log('🚀 [useMetadata] loadMetadata CALLED for:', { id, type });
console.log('🔍 [useMetadata] loadMetadata started:', {
id,
type,
@ -541,6 +543,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 });
@ -731,7 +745,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
console.log('🔍 [useMetadata] Starting parallel data fetch:', { type, actualId, addonId, apiTimeout: API_TIMEOUT });
if (__DEV__) logger.log('[loadMetadata] fetching addon metadata', { type, actualId, addonId });
let contentResult = null;
let contentResult: any = null;
let lastError = null;
// Check if user prefers external meta addons
@ -814,11 +828,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
const [content, castData] = await Promise.allSettled([
// Load content with timeout and retry
withRetry(async () => {
console.log('⚡ [useMetadata] Calling catalogService.getEnhancedContentDetails...');
console.log('🔍 [useMetadata] Calling catalogService.getEnhancedContentDetails:', { type, actualId, addonId });
const result = await withTimeout(
catalogService.getEnhancedContentDetails(type, actualId, addonId),
API_TIMEOUT
);
console.log('✅ [useMetadata] catalogService returned:', result ? 'DATA' : 'NULL');
// Store the actual ID used (could be IMDB)
if (actualId.startsWith('tt')) {
setImdbId(actualId);
@ -2030,8 +2046,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// Start Stremio request using the converted episode ID format
if (__DEV__) console.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId);
const requestedContentType = isCollection ? 'movie' : type;
const contentType = requestedContentType;
// 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
const contentType = isCollection ? 'movie' : type;
if (__DEV__) console.log(`🎬 [loadEpisodeStreams] Using content type: ${contentType} for ${isCollection ? 'collection' : type}`);
processStremioSource(contentType, stremioEpisodeId, true);

View file

@ -57,6 +57,8 @@ 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 MalLibraryScreen from '../screens/MalLibraryScreen';
import SimklSettingsScreen from '../screens/SimklSettingsScreen';
import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
import ThemeScreen from '../screens/ThemeScreen';
@ -152,6 +154,7 @@ export type RootStackParamList = {
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
backdrop?: string;
videoType?: string;
releaseDate?: string;
groupedEpisodes?: { [seasonNumber: number]: any[] };
};
PlayerAndroid: {
@ -172,6 +175,7 @@ export type RootStackParamList = {
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
backdrop?: string;
videoType?: string;
releaseDate?: string;
groupedEpisodes?: { [seasonNumber: number]: any[] };
};
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
@ -190,6 +194,8 @@ export type RootStackParamList = {
HomeScreenSettings: undefined;
HeroCatalogs: undefined;
TraktSettings: undefined;
MalSettings: undefined;
MalLibrary: undefined;
SimklSettings: undefined;
PlayerSettings: undefined;
ThemeSettings: undefined;
@ -1573,6 +1579,36 @@ 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="MalLibrary"
component={MalLibraryScreen}
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="SimklSettings"
component={SimklSettingsScreen}

View file

@ -31,30 +31,13 @@ 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';
import { CalendarEpisode, CalendarSection } from '../types/calendar';
const { width } = Dimensions.get('window');
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
interface CalendarEpisode {
id: string;
seriesId: string;
title: string;
seriesName: string;
poster: string;
releaseDate: string;
season: number;
episode: number;
overview: string;
vote_average: number;
still_path: string | null;
season_poster_path: string | null;
}
interface CalendarSection {
title: string;
data: CalendarEpisode[];
}
const CalendarScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -75,14 +58,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 +174,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 +203,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 +257,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 +309,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.filter(ep => ep.season !== 0), 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 +382,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 +408,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 +428,8 @@ const CalendarScreen = () => {
episodes={allEpisodes}
onSelectDate={handleDateSelect}
/>
</>
)}
{selectedDate && filteredEpisodes.length > 0 ? (
<FlatList
@ -362,9 +466,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 +664,41 @@ const styles = StyleSheet.create({
fontSize: 14,
marginBottom: 4,
},
tabContainer: {
flexDirection: 'row',
marginVertical: 12,
paddingHorizontal: 16,
gap: 12,
},
tabButton: {
flex: 1,
paddingVertical: 10,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
tabText: {
fontSize: 14,
fontWeight: '600',
color: 'rgba(255, 255, 255, 0.7)',
},
genreContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
marginTop: 6,
},
genreChip: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
genreText: {
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
},
});
export default CalendarScreen;

View file

@ -246,6 +246,10 @@ const SkeletonLoader = () => {
);
};
import { MalApiService, MalSync, MalAnimeNode } from '../services/mal';
// ... other imports
const LibraryScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -254,8 +258,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' | 'simkl' | 'movies' | 'series'>('movies');
const [filter, setFilter] = useState<'trakt' | 'simkl' | '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 [showSimklContent, setShowSimklContent] = useState(false);
const [selectedSimklFolder, setSelectedSimklFolder] = useState<string | null>(null);
@ -1473,6 +1481,68 @@ const LibraryScreen = () => {
);
};
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, 100);
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 renderSimklCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
@ -1501,6 +1571,79 @@ const LibraryScreen = () => {
</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 renderSimklContent = () => {
if (simklLoading) {
return (
@ -1599,7 +1742,7 @@ const LibraryScreen = () => {
);
};
const renderFilter = (filterType: 'trakt' | 'simkl' | 'movies' | 'series', label: string) => {
const renderFilter = (filterType: 'trakt' | 'simkl' | 'movies' | 'series' | 'mal', label: string, iconName?: keyof typeof MaterialIcons.glyphMap) => {
const isActive = filter === filterType;
return (
@ -1622,7 +1765,7 @@ const LibraryScreen = () => {
}
if (filterType === 'simkl') {
if (!simklAuthenticated) {
navigation.navigate('Settings');
navigation.navigate('SimklSettings');
} else {
setShowSimklContent(true);
setSelectedSimklFolder(null);
@ -1630,10 +1773,19 @@ const LibraryScreen = () => {
}
return;
}
if (filterType === 'mal') {
navigation.navigate('MalLibrary');
return;
}
setShowTraktContent(false);
setShowSimklContent(false);
setFilter(filterType);
}}
activeOpacity={0.7}
>
{iconName && (
<MaterialIcons name={iconName} size={20} color={isActive ? currentTheme.colors.white : currentTheme.colors.mediumGray} style={styles.filterIcon} />
)}
<Text
style={[
styles.filterText,
@ -1647,6 +1799,7 @@ const LibraryScreen = () => {
);
};
const renderContent = () => {
if (loading) {
return <SkeletonLoader />;
@ -1742,15 +1895,21 @@ const LibraryScreen = () => {
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{!showTraktContent && !showSimklContent && (
<View style={styles.filtersContainer}>
{renderFilter('trakt', 'Trakt')}
{renderFilter('simkl', 'SIMKL')}
{renderFilter('movies', t('search.movies'))}
{renderFilter('series', t('search.tv_shows'))}
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.filtersContainer}
contentContainerStyle={styles.filtersContent}
>
{renderFilter('trakt', 'Trakt', 'pan-tool')}
{renderFilter('simkl', 'SIMKL', 'video-library')}
{renderFilter('mal', 'MAL', 'book')}
{renderFilter('movies', t('search.movies'), 'movie')}
{renderFilter('series', t('search.tv_shows'), 'live-tv')}
</ScrollView>
)}
{showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : renderContent()}
{showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : (filter === 'mal' ? renderMalContent() : renderContent())}
</View>
{/* Sync FAB - Bottom Right (only in manual mode) */}
@ -1848,15 +2007,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',
@ -2121,6 +2283,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',
},
syncFab: {
position: 'absolute',
right: 16,

View file

@ -0,0 +1,318 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
FlatList,
ActivityIndicator,
SafeAreaView,
StatusBar,
Platform,
Dimensions,
RefreshControl,
ScrollView,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image';
import { MalApiService } from '../services/mal/MalApi';
import { MalAnimeNode, MalListStatus } from '../types/mal';
import { useTheme } from '../contexts/ThemeContext';
import { useTranslation } from 'react-i18next';
import { logger } from '../utils/logger';
import { MalEditModal } from '../components/mal/MalEditModal';
import { MalSync } from '../services/mal/MalSync';
const { width } = Dimensions.get('window');
const ITEM_WIDTH = width * 0.35;
const ITEM_HEIGHT = ITEM_WIDTH * 1.5;
const MalLibraryScreen: React.FC = () => {
const { t } = useTranslation();
const navigation = useNavigation<any>();
const { currentTheme } = useTheme();
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [groupedList, setGroupedList] = useState<Record<MalListStatus, MalAnimeNode[]>>({
watching: [],
completed: [],
on_hold: [],
dropped: [],
plan_to_watch: [],
});
const [selectedAnime, setSelectedAnime] = useState<MalAnimeNode | null>(null);
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const fetchMalList = useCallback(async () => {
try {
setIsLoading(true);
let allItems: MalAnimeNode[] = [];
let offset = 0;
let hasMore = true;
while (hasMore && offset < 1000) {
const response = await MalApiService.getUserList(undefined, offset, 100);
if (response.data && response.data.length > 0) {
allItems = [...allItems, ...response.data];
offset += response.data.length;
hasMore = !!response.paging.next;
} else {
hasMore = false;
}
}
const grouped: Record<MalListStatus, MalAnimeNode[]> = {
watching: [],
completed: [],
on_hold: [],
dropped: [],
plan_to_watch: [],
};
allItems.forEach(item => {
const status = item.list_status.status;
if (grouped[status]) {
grouped[status].push(item);
}
});
setGroupedList(grouped);
} catch (error) {
logger.error('[MalLibrary] Failed to fetch list', error);
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
fetchMalList();
}, [fetchMalList]);
const handleRefresh = () => {
setIsRefreshing(true);
fetchMalList();
};
const handleItemPress = async (item: MalAnimeNode) => {
// Requirement 8: Resolve correct Cinemata / TMDB / IMDb ID
const malId = item.node.id;
// Use MalSync API to get external IDs
const { imdbId } = await MalSync.getIdsFromMalId(malId);
if (imdbId) {
navigation.navigate('Metadata', {
id: imdbId,
type: item.node.media_type === 'movie' ? 'movie' : 'series'
});
} else {
// Fallback: Navigate to Search with the title if ID mapping is missing
logger.warn(`[MalLibrary] Could not resolve IMDb ID for MAL:${malId}. Falling back to Search.`);
navigation.navigate('Search', { query: item.node.title });
}
};
const renderAnimeItem = ({ item }: { item: MalAnimeNode }) => (
<TouchableOpacity
style={styles.animeItem}
onPress={() => handleItemPress(item)}
activeOpacity={0.7}
>
<FastImage
source={{ uri: item.node.main_picture?.medium }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
/>
<View style={styles.badgeContainer}>
<View style={[styles.episodeBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.episodeText}>
{item.list_status.num_episodes_watched} / {item.node.num_episodes || '?'}
</Text>
</View>
</View>
<Text style={[styles.animeTitle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={2}>
{item.node.title}
</Text>
{item.list_status.score > 0 && (
<View style={styles.scoreRow}>
<MaterialIcons name="star" size={12} color="#FFD700" />
<Text style={[styles.scoreText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.list_status.score}
</Text>
</View>
)}
{/* Requirement 5: Manual update button */}
<TouchableOpacity
style={styles.editButton}
onPress={() => {
setSelectedAnime(item);
setIsEditModalVisible(true);
}}
>
<MaterialIcons name="edit" size={16} color={currentTheme.colors.white} />
</TouchableOpacity>
</TouchableOpacity>
);
const renderSection = (status: MalListStatus, title: string, icon: string) => {
const data = groupedList[status];
if (data.length === 0) return null;
return (
<View style={styles.sectionContainer}>
<View style={styles.sectionHeader}>
<MaterialIcons name={icon as any} size={20} color={currentTheme.colors.primary} style={{ marginRight: 8 }} />
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
{title} ({data.length})
</Text>
</View>
<FlatList
data={data}
renderItem={renderAnimeItem}
keyExtractor={item => item.node.id.toString()}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.carouselContent}
snapToInterval={ITEM_WIDTH + 12}
decelerationRate="fast"
/>
</View>
);
};
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
translucent
/>
<SafeAreaView style={styles.safeArea}>
<View style={styles.header}>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
MyAnimeList
</Text>
{/* Requirement 6: Manual Sync Button */}
<TouchableOpacity onPress={handleRefresh} style={styles.syncButton} disabled={isLoading}>
{isLoading ? (
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
) : (
<MaterialIcons name="sync" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
</View>
{!isLoading || isRefreshing ? (
<ScrollView
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} tintColor={currentTheme.colors.primary} />
}
contentContainerStyle={{ paddingBottom: 40 }}
>
{renderSection('watching', 'Watching', 'play-circle-outline')}
{renderSection('plan_to_watch', 'Plan to Watch', 'bookmark-outline')}
{renderSection('completed', 'Completed', 'check-circle-outline')}
{renderSection('on_hold', 'On Hold', 'pause-circle-outline')}
{renderSection('dropped', 'Dropped', 'highlight-off')}
</ScrollView>
) : (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
</View>
)}
{selectedAnime && (
<MalEditModal
visible={isEditModalVisible}
anime={selectedAnime}
onClose={() => {
setIsEditModalVisible(false);
setSelectedAnime(null);
}}
onUpdateSuccess={fetchMalList}
/>
)}
</SafeAreaView>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
safeArea: { flex: 1, paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 0 },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
justifyContent: 'space-between'
},
backButton: { padding: 4 },
headerTitle: { fontSize: 20, fontWeight: '700', flex: 1, marginLeft: 16 },
syncButton: { padding: 4 },
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
sectionContainer: { marginVertical: 12 },
sectionHeader: { paddingHorizontal: 16, marginBottom: 8 },
sectionTitle: { fontSize: 18, fontWeight: '700' },
carouselContent: { paddingHorizontal: 10 },
animeItem: {
width: ITEM_WIDTH,
marginHorizontal: 6,
marginBottom: 10,
},
poster: {
width: ITEM_WIDTH,
height: ITEM_HEIGHT,
borderRadius: 8,
backgroundColor: '#333',
},
badgeContainer: {
position: 'absolute',
top: 6,
left: 6,
},
episodeBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
},
episodeText: {
color: 'white',
fontSize: 10,
fontWeight: '700',
},
animeTitle: {
fontSize: 12,
fontWeight: '600',
marginTop: 6,
lineHeight: 16,
},
scoreRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 2,
},
scoreText: {
fontSize: 11,
marginLeft: 4,
},
editButton: {
position: 'absolute',
top: 6,
right: 6,
backgroundColor: 'rgba(0,0,0,0.6)',
padding: 6,
borderRadius: 15,
}
});
export default MalLibraryScreen;

View file

@ -0,0 +1,555 @@
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 { MalSync } from '../services/mal/MalSync';
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 [autoAddEnabled, setAutoAddEnabled] = useState(mmkvStorage.getBoolean('mal_auto_add') ?? true);
const [autoLibrarySyncEnabled, setAutoLibrarySyncEnabled] = useState(mmkvStorage.getBoolean('mal_auto_sync_to_library') ?? false);
const [includeNsfwEnabled, setIncludeNsfwEnabled] = useState(mmkvStorage.getBoolean('mal_include_nsfw') ?? 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);
};
const toggleAutoAdd = (val: boolean) => {
setAutoAddEnabled(val);
mmkvStorage.setBoolean('mal_auto_add', val);
};
const toggleAutoLibrarySync = (val: boolean) => {
setAutoLibrarySyncEnabled(val);
mmkvStorage.setBoolean('mal_auto_sync_to_library', val);
};
const toggleIncludeNsfw = (val: boolean) => {
setIncludeNsfwEnabled(val);
mmkvStorage.setBoolean('mal_include_nsfw', 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>
<View style={styles.profileDetailRow}>
<MaterialIcons name="fingerprint" size={14} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.profileDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
ID: {userProfile.id}
</Text>
</View>
{userProfile.location && (
<View style={styles.profileDetailRow}>
<MaterialIcons name="location-on" size={14} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.profileDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
{userProfile.location}
</Text>
</View>
)}
{userProfile.birthday && (
<View style={styles.profileDetailRow}>
<MaterialIcons name="cake" size={14} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.profileDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
{userProfile.birthday}
</Text>
</View>
)}
</View>
</View>
{userProfile.anime_statistics && (
<View style={styles.statsContainer}>
<View style={styles.statsRow}>
<View style={styles.statBox}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userProfile.anime_statistics.num_items}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>Total</Text>
</View>
<View style={styles.statBox}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userProfile.anime_statistics.num_days_watched.toFixed(1)}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>Days</Text>
</View>
<View style={styles.statBox}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userProfile.anime_statistics.mean_score.toFixed(1)}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>Mean</Text>
</View>
</View>
<View style={[styles.statGrid, { borderColor: currentTheme.colors.border }]}>
<View style={styles.statGridItem}>
<View style={[styles.statusDot, { backgroundColor: '#2DB039' }]} />
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>Watching</Text>
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
{userProfile.anime_statistics.num_items_watching}
</Text>
</View>
<View style={styles.statGridItem}>
<View style={[styles.statusDot, { backgroundColor: '#26448F' }]} />
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>Completed</Text>
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
{userProfile.anime_statistics.num_items_completed}
</Text>
</View>
<View style={styles.statGridItem}>
<View style={[styles.statusDot, { backgroundColor: '#F9D457' }]} />
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>On Hold</Text>
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
{userProfile.anime_statistics.num_items_on_hold}
</Text>
</View>
<View style={styles.statGridItem}>
<View style={[styles.statusDot, { backgroundColor: '#A12F31' }]} />
<Text style={[styles.statGridLabel, { color: currentTheme.colors.highEmphasis }]}>Dropped</Text>
<Text style={[styles.statGridValue, { color: currentTheme.colors.highEmphasis }]}>
{userProfile.anime_statistics.num_items_dropped}
</Text>
</View>
</View>
</View>
)}
<View style={styles.actionButtonsRow}>
<TouchableOpacity
style={[styles.smallButton, { backgroundColor: currentTheme.colors.primary, flex: 1, marginRight: 8 }]}
onPress={async () => {
setIsLoading(true);
try {
const synced = await MalSync.syncMalToLibrary();
if (synced) {
openAlert('Sync Complete', 'MAL data has been refreshed.');
} else {
openAlert('Sync Failed', 'Could not refresh MAL data.');
}
} catch {
openAlert('Sync Failed', 'Could not refresh MAL data.');
} finally {
setIsLoading(false);
}
}}
>
<MaterialIcons name="sync" size={18} color="white" style={{ marginRight: 6 }} />
<Text style={styles.buttonText}>Sync</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.smallButton, { backgroundColor: currentTheme.colors.error, width: 100 }]}
onPress={handleSignOut}
>
<Text style={styles.buttonText}>Sign Out</Text>
</TouchableOpacity>
</View>
</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>
<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 MAL Sync
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Global switch to enable or disable all MyAnimeList features.
</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 your progress on MAL when you finish watching an episode (&gt;=90% completion).
</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 style={styles.settingItem}>
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
Auto Add Anime
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
If an anime is not in your MAL list, it will be added automatically when you start watching.
</Text>
</View>
<Switch
value={autoAddEnabled}
onValueChange={toggleAutoAdd}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={autoAddEnabled ? 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-Sync to Library
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Automatically add items from your MAL 'Watching' list to your Nuvio Library.
</Text>
</View>
<Switch
value={autoLibrarySyncEnabled}
onValueChange={toggleAutoLibrarySync}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={autoLibrarySyncEnabled ? 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 }]}>
Include NSFW Content
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Allow NSFW entries to be returned when fetching your MAL list.
</Text>
</View>
<Switch
value={includeNsfwEnabled}
onValueChange={toggleIncludeNsfw}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={includeNsfwEnabled ? 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' },
profileDetailRow: { flexDirection: 'row', alignItems: 'center', marginTop: 2 },
profileDetailText: { fontSize: 12, marginLeft: 4 },
statsContainer: { marginTop: 20 },
statsRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 16 },
statBox: { alignItems: 'center', flex: 1 },
statValue: { fontSize: 18, fontWeight: 'bold' },
statLabel: { fontSize: 12, marginTop: 2 },
statGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
borderTopWidth: 1,
paddingTop: 16,
gap: 12
},
statGridItem: {
flexDirection: 'row',
alignItems: 'center',
width: '45%',
marginBottom: 8
},
statusDot: { width: 8, height: 8, borderRadius: 4, marginRight: 8 },
statGridLabel: { fontSize: 13, flex: 1 },
statGridValue: { fontSize: 13, fontWeight: '600' },
actionButtonsRow: { flexDirection: 'row', marginTop: 20 },
smallButton: {
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
signOutButton: { marginTop: 20 },
settingsSection: { padding: 20 },
sectionTitle: { fontSize: 18, fontWeight: '600', marginBottom: 16 },
settingItem: { marginBottom: 16 },
settingContent: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
settingTextContainer: { flex: 1, marginRight: 16 },
settingLabel: { fontSize: 15, fontWeight: '500', marginBottom: 4 },
settingDescription: { fontSize: 14 },
noteContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 8,
borderWidth: 1,
marginBottom: 20,
marginTop: -8,
},
noteText: {
fontSize: 13,
marginLeft: 8,
flex: 1,
lineHeight: 18,
},
});
export default MalSettingsScreen;

View file

@ -85,17 +85,15 @@ const MemoizedRatingsSection = memo(RatingsSection);
const MemoizedCommentsSection = memo(CommentsSection);
const MemoizedCastDetailsModal = memo(CastDetailsModal);
// ... other imports
const MetadataScreen: React.FC = () => {
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string; addonId?: string }>, string>>();
useEffect(() => { console.log('✅ MetadataScreen MOUNTED'); }, []);
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 +103,35 @@ 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 [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const [shouldLoadSecondaryData, setShouldLoadSecondaryData] = useState(false);
const [isScreenFocused, setIsScreenFocused] = useState(true);
const [isContentReady, setIsContentReady] = useState(false);
const [showCastModal, setShowCastModal] = useState(false);
// Enhanced responsive sizing for tablets and TV screens
const deviceWidth = Dimensions.get('window').width;
const deviceHeight = Dimensions.get('window').height;
@ -137,12 +164,6 @@ const MetadataScreen: React.FC = () => {
}
}, [deviceType]);
// Optimized state management - reduced state variables
const [isContentReady, setIsContentReady] = useState(false);
const [showCastModal, setShowCastModal] = useState(false);
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const [shouldLoadSecondaryData, setShouldLoadSecondaryData] = useState(false);
const [isScreenFocused, setIsScreenFocused] = useState(true);
// Source switching removed
const transitionOpacity = useSharedValue(1);
const interactionComplete = useRef(false);
@ -170,30 +191,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:', {
@ -896,25 +893,13 @@ const MetadataScreen: React.FC = () => {
// Show error if exists
if (metadataError || (!loading && !metadata)) {
console.log('🔍 [MetadataScreen] Showing error component:', {
hasError: !!metadataError,
errorMessage: metadataError,
isLoading: loading,
hasMetadata: !!metadata,
loadingState: loading
});
console.log('❌ MetadataScreen ERROR state:', { metadataError, loading, hasMetadata: !!metadata });
return ErrorComponent;
}
// Show loading screen if metadata is not yet available or exit animation hasn't completed
if (loading || !isContentReady || !loadingScreenExited) {
console.log('🔍 [MetadataScreen] Showing loading screen:', {
isLoading: loading,
isContentReady,
loadingScreenExited,
hasMetadata: !!metadata,
errorMessage: metadataError
});
console.log('⏳ MetadataScreen LOADING state:', { loading, isContentReady, loadingScreenExited, hasMetadata: !!metadata });
return (
<MetadataLoadingScreen
ref={loadingScreenRef}

View file

@ -10,7 +10,7 @@ import {
ScrollView,
Platform,
} from 'react-native';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { useNavigation, useFocusEffect, useRoute } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons';
@ -63,6 +63,7 @@ const SearchScreen = () => {
const { t } = useTranslation();
const { settings } = useSettings();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const route = useRoute<any>();
const { addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection, isInWatchlist, isInCollection } = useTraktContext();
const { showSuccess, showInfo } = useToast();
const [query, setQuery] = useState('');
@ -153,18 +154,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 +189,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;
}
@ -484,6 +470,13 @@ const SearchScreen = () => {
useFocusEffect(
useCallback(() => {
isMounted.current = true;
// Check for route query param
if (route.params?.query && route.params.query !== query) {
setQuery(route.params.query);
// The query effect will trigger debouncedSearch automatically
}
return () => {
isMounted.current = false;
if (liveSearchHandle.current) {
@ -492,7 +485,7 @@ const SearchScreen = () => {
}
debouncedSearch.cancel();
};
}, [debouncedSearch])
}, [debouncedSearch, route.params?.query])
);
const performLiveSearch = async (searchQuery: string) => {
@ -703,7 +696,7 @@ const SearchScreen = () => {
const handleGenreSelect = (genre: string | null) => {
setSelectedDiscoverGenre(genre);
// Save genre setting - this will save empty string for null (All genres)
// Save genre setting
saveDiscoverSettings(selectedDiscoverType, selectedCatalog, genre);
genreSheetRef.current?.dismiss();

View file

@ -12,6 +12,7 @@ import {
Platform,
Dimensions,
FlatList,
Image,
} from 'react-native';
import { BottomSheetModal, BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { useTranslation } from 'react-i18next';
@ -391,7 +392,7 @@ const SettingsScreen: React.FC = () => {
}
renderControl={() => <ChevronRight />}
onPress={() => (navigation as any).navigate('SyncSettings')}
isLast={!showTraktItem && !showSimklItem}
isLast={!showTraktItem && !showSimklItem && !isItemVisible('mal')}
isTablet={isTablet}
/>
)}
@ -402,7 +403,7 @@ const SettingsScreen: React.FC = () => {
customIcon={<TraktIcon size={isTablet ? 24 : 20} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')}
isLast={!showSimklItem}
isLast={!showSimklItem && !isItemVisible('mal')}
isTablet={isTablet}
/>
)}
@ -413,6 +414,17 @@ const SettingsScreen: React.FC = () => {
customIcon={<SimklIcon size={isTablet ? 24 : 20} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('SimklSettings')}
isLast={!isItemVisible('mal')}
isTablet={isTablet}
/>
)}
{isItemVisible('mal') && (
<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}
/>
@ -701,7 +713,7 @@ const SettingsScreen: React.FC = () => {
contentContainerStyle={styles.scrollContent}
>
{/* Account */}
{(settingsConfig?.categories?.['account']?.visible !== false) && (showTraktItem || showSimklItem || showCloudSyncItem) && (
{(settingsConfig?.categories?.['account']?.visible !== false) && (showTraktItem || showSimklItem || showCloudSyncItem || isItemVisible('mal')) && (
<SettingsCard title={t('settings.account').toUpperCase()}>
{showCloudSyncItem && (
<SettingItem
@ -716,7 +728,7 @@ const SettingsScreen: React.FC = () => {
}
renderControl={() => <ChevronRight />}
onPress={() => (navigation as any).navigate('SyncSettings')}
isLast={!showTraktItem && !showSimklItem}
isLast={!showTraktItem && !showSimklItem && !isItemVisible('mal')}
/>
)}
{showTraktItem && (
@ -726,7 +738,7 @@ const SettingsScreen: React.FC = () => {
customIcon={<TraktIcon size={20} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')}
isLast={!showSimklItem}
isLast={!showSimklItem && !isItemVisible('mal')}
/>
)}
{showSimklItem && (
@ -736,7 +748,17 @@ const SettingsScreen: React.FC = () => {
customIcon={<SimklIcon size={20} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('SimklSettings')}
isLast={true}
isLast={!isItemVisible('mal')}
/>
)}
{isItemVisible('mal') && (
<SettingItem
title="MyAnimeList"
description="Sync with MyAnimeList"
customIcon={<Image source={require('../../assets/rating-icons/mal-icon.png')} style={{ width: 20, height: 20, borderRadius: 4 }} resizeMode="contain" />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('MalSettings')}
isLast
/>
)}
</SettingsCard>

View file

@ -364,6 +364,7 @@ export const useStreamsScreen = () => {
const streamsToPass = selectedEpisode ? episodeStreams : groupedStreams;
const streamName = stream.name || stream.title || 'Unnamed Stream';
const resolvedStreamProvider = streamProvider;
const releaseDate = type === 'movie' ? metadata?.released : currentEpisode?.air_date;
// Save stream to cache
try {
@ -432,6 +433,7 @@ export const useStreamsScreen = () => {
availableStreams: streamsToPass,
backdrop: metadata?.banner || bannerImage,
videoType,
releaseDate,
} as any);
},
[metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage, settings.streamCacheTTL]
@ -646,8 +648,7 @@ export const useStreamsScreen = () => {
hasDoneInitialLoadRef.current = true;
try {
const stremioType = type === 'tv' ? 'series' : type;
const hasStremioProviders = await stremioService.hasStreamProviders(stremioType);
const hasStremioProviders = await stremioService.hasStreamProviders(type);
const hasLocalScrapers = settings.enableLocalScrapers && (await localScraperService.hasScrapers());
const hasProviders = hasStremioProviders || hasLocalScrapers;

View file

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

View file

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

View file

@ -86,6 +86,13 @@ export interface StreamingContent {
[key: string]: any;
};
imdb_id?: string;
mal_id?: number;
external_ids?: {
mal_id?: number;
imdb_id?: string;
tmdb_id?: number;
tvdb_id?: number;
};
slug?: string;
releaseInfo?: string;
traktSource?: 'watchlist' | 'continue-watching' | 'watched';

View file

@ -1,6 +1,7 @@
import axios from 'axios';
import { logger } from '../utils/logger';
import { tmdbService } from './tmdbService';
import { ArmSyncService } from './mal/ArmSyncService';
/**
* IntroDB API service for fetching TV show intro timestamps
@ -304,7 +305,8 @@ export async function getSkipTimes(
season: number,
episode: number,
malId?: string,
kitsuId?: string
kitsuId?: string,
releaseDate?: string
): Promise<SkipInterval[]> {
// 1. Try IntroDB (TV Shows) first
if (imdbId) {
@ -316,7 +318,22 @@ export async function getSkipTimes(
// 2. Try AniSkip (Anime) if we have MAL ID or Kitsu ID
let finalMalId = malId;
let finalEpisode = episode;
// If we have IMDb ID and Release Date, try ArmSyncService to resolve exact MAL ID and Episode
if (!finalMalId && imdbId && releaseDate) {
try {
const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate);
if (armResult) {
finalMalId = armResult.malId.toString();
finalEpisode = armResult.episode;
logger.log(`[IntroService] ArmSync resolved: MAL ${finalMalId} Ep ${finalEpisode}`);
}
} catch (e) {
logger.warn('[IntroService] ArmSync failed', e);
}
}
// If we have Kitsu ID but no MAL ID, try to resolve it
if (!finalMalId && kitsuId) {
logger.log(`[IntroService] Resolving MAL ID from Kitsu ID: ${kitsuId}`);
@ -337,8 +354,8 @@ export async function getSkipTimes(
}
if (finalMalId) {
logger.log(`[IntroService] Fetching AniSkip for MAL ID: ${finalMalId} Ep: ${episode}`);
const aniSkipIntervals = await fetchFromAniSkip(finalMalId, episode);
logger.log(`[IntroService] Fetching AniSkip for MAL ID: ${finalMalId} Ep: ${finalEpisode}`);
const aniSkipIntervals = await fetchFromAniSkip(finalMalId, finalEpisode);
if (aniSkipIntervals.length > 0) {
logger.log(`[IntroService] Found ${aniSkipIntervals.length} skip intervals from AniSkip`);
return aniSkipIntervals;
@ -386,4 +403,4 @@ export const introService = {
verifyApiKey
};
export default introService;
export default introService;

View file

@ -0,0 +1,192 @@
import axios from 'axios';
import { logger } from '../../utils/logger';
interface ArmEntry {
anidb?: number;
anilist?: number;
'anime-planet'?: string;
anisearch?: number;
imdb?: string;
kitsu?: number;
livechart?: number;
'notify-moe'?: string;
themoviedb?: number;
thetvdb?: number;
myanimelist?: number;
}
interface DateSyncResult {
malId: number;
episode: number;
title?: string;
}
const JIKAN_BASE = 'https://api.jikan.moe/v4';
const ARM_BASE = 'https://arm.haglund.dev/api/v2';
export const ArmSyncService = {
/**
* Resolves the correct MyAnimeList ID and Episode Number using ARM (for ID mapping)
* and Jikan (for Air Date matching).
*
* @param imdbId The IMDb ID of the show
* @param releaseDateStr The air date of the episode (YYYY-MM-DD)
* @param dayIndex The 0-based index of this episode among others released on the same day (optional)
* @returns {Promise<DateSyncResult | null>} The resolved MAL ID and Episode number
*/
resolveByDate: async (imdbId: string, releaseDateStr: string, dayIndex?: number): Promise<DateSyncResult | null> => {
try {
// Basic validation: ensure date is in YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}/.test(releaseDateStr)) {
logger.warn(`[ArmSync] Invalid date format provided: ${releaseDateStr}`);
return null;
}
logger.log(`[ArmSync] Resolving ${imdbId} for date ${releaseDateStr}...`);
// 1. Fetch Candidates from ARM
const armRes = await axios.get<ArmEntry[]>(`${ARM_BASE}/imdb`, {
params: { id: imdbId }
});
const malIds = armRes.data
.map(entry => entry.myanimelist)
.filter((id): id is number => !!id);
if (malIds.length === 0) {
logger.warn(`[ArmSync] No MAL IDs found in ARM for ${imdbId}`);
return null;
}
logger.log(`[ArmSync] Found candidates: ${malIds.join(', ')}`);
// 2. Validate Candidates
return await ArmSyncService.resolveFromMalCandidates(malIds, releaseDateStr, dayIndex);
} catch (e) {
logger.error('[ArmSync] Resolution failed:', e);
}
return null;
},
/**
* Resolves the correct MyAnimeList ID and Episode Number using ARM (for ID mapping)
* and Jikan (for Air Date matching) using a TMDB ID.
*
* @param tmdbId The TMDB ID of the show
* @param releaseDateStr The air date of the episode (YYYY-MM-DD)
* @param dayIndex The 0-based index of this episode among others released on the same day
* @returns {Promise<DateSyncResult | null>} The resolved MAL ID and Episode number
*/
resolveByTmdb: async (tmdbId: number, releaseDateStr: string, dayIndex?: number): Promise<DateSyncResult | null> => {
try {
if (!/^\d{4}-\d{2}-\d{2}/.test(releaseDateStr)) return null;
logger.log(`[ArmSync] Resolving TMDB ${tmdbId} for date ${releaseDateStr}...`);
// 1. Fetch Candidates from ARM using TMDB ID
const armRes = await axios.get<ArmEntry[]>(`${ARM_BASE}/tmdb`, {
params: { id: tmdbId }
});
const malIds = armRes.data
.map(entry => entry.myanimelist)
.filter((id): id is number => !!id);
if (malIds.length === 0) return null;
logger.log(`[ArmSync] Found candidates for TMDB ${tmdbId}: ${malIds.join(', ')}`);
// 2. Validate Candidates
return await ArmSyncService.resolveFromMalCandidates(malIds, releaseDateStr, dayIndex);
} catch (e) {
logger.error('[ArmSync] TMDB resolution failed:', e);
}
return null;
},
/**
* Internal helper to find the correct MAL ID from a list of candidates based on date
*/
resolveFromMalCandidates: async (malIds: number[], releaseDateStr: string, dayIndex?: number): Promise<DateSyncResult | null> => {
// Helper to delay (Jikan Rate Limit: 3 req/sec)
const delay = (ms: number) => new Promise(res => setTimeout(res, ms));
for (const malId of malIds) {
await delay(500); // Respect rate limits
try {
const detailsRes = await axios.get(`${JIKAN_BASE}/anime/${malId}`);
const anime = detailsRes.data.data;
const startDateStr = anime.aired?.from ? new Date(anime.aired.from).toISOString().split('T')[0] : null;
const endDateStr = anime.aired?.to ? new Date(anime.aired.to).toISOString().split('T')[0] : null;
// Date Matching Logic with Timezone Tolerance (2 days)
let isMatch = false;
if (startDateStr) {
const startLimit = new Date(startDateStr);
startLimit.setDate(startLimit.getDate() - 2); // Allow release date to be up to 2 days before official start
const startLimitStr = startLimit.toISOString().split('T')[0];
// Check if our episode date is >= Season Start Date (with tolerance)
if (releaseDateStr >= startLimitStr) {
// If season has ended, our episode must be <= Season End Date
if (!endDateStr || releaseDateStr <= endDateStr) {
isMatch = true;
}
}
}
if (isMatch) {
logger.log(`[ArmSync] Match found! ID ${malId} covers ${releaseDateStr}`);
// 3. Find Exact Episode (with tolerance)
await delay(500);
const epsRes = await axios.get(`${JIKAN_BASE}/anime/${malId}/episodes`);
const episodes = epsRes.data.data;
const matchingEpisodes = episodes.filter((ep: any) => {
if (!ep.aired) return false;
try {
const epDate = new Date(ep.aired);
const targetDate = new Date(releaseDateStr);
// Calculate difference in days
const diffTime = Math.abs(targetDate.getTime() - epDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
// Match if within 2 days (48 hours)
return diffDays <= 2;
} catch (e) {
return false;
}
});
if (matchingEpisodes.length > 0) {
// Sort matching episodes by their mal_id to ensure consistent ordering
matchingEpisodes.sort((a: any, b: any) => a.mal_id - b.mal_id);
let matchEp = matchingEpisodes[0];
// If multiple episodes match the same day, use dayIndex to pick the correct one
if (matchingEpisodes.length > 1 && dayIndex !== undefined) {
// If the dayIndex is within bounds, pick it. Otherwise, pick the last one.
const idx = Math.min(dayIndex, matchingEpisodes.length - 1);
matchEp = matchingEpisodes[idx];
logger.log(`[ArmSync] Disambiguated same-day release using dayIndex ${dayIndex} -> picked Ep #${matchEp.mal_id}`);
}
logger.log(`[ArmSync] Episode resolved: #${matchEp.mal_id} (${matchEp.title})`);
return {
malId,
episode: matchEp.mal_id,
title: matchEp.title
};
}
}
} catch (e) {
logger.warn(`[ArmSync] Failed to check candidate ${malId}:`, e);
}
}
return null;
}
};

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

@ -0,0 +1,125 @@
import axios from 'axios';
import { mmkvStorage } from '../mmkvStorage';
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',
nsfw: mmkvStorage.getBoolean('mal_include_nsfw') ?? true
},
});
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,
isRewatching?: boolean
) => {
const data: any = {
status,
num_watched_episodes: episode,
is_rewatching: isRewatching || false
};
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' }
});
},
removeFromList: async (malId: number) => {
return api.delete(`/anime/${malId}/my_list_status`);
},
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', {
params: {
fields: 'id,name,picture,gender,birthday,location,joined_at,anime_statistics,time_zone'
}
});
return response.data;
} catch (error) {
console.error('Failed to get user info', error);
throw error;
}
},
getMyListStatus: async (malId: number): Promise<{ my_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 };
}
}
};

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

@ -0,0 +1,260 @@
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;
}
isAuthenticated(): boolean {
return this.getToken() !== null;
}
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();

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

@ -0,0 +1,561 @@
import { mmkvStorage } from '../mmkvStorage';
import { MalApiService } from './MalApi';
import { MalAuth } from './MalAuth';
import { MalListStatus, MalAnimeNode } from '../../types/mal';
import { catalogService } from '../catalogService';
import { ArmSyncService } from './ArmSyncService';
import { logger } from '../../utils/logger';
import axios from 'axios';
const MAPPING_PREFIX = 'mal_map_';
const getTitleCacheKey = (title: string, type: 'movie' | 'series', season = 1) =>
`${MAPPING_PREFIX}${title.trim()}_${type}_${season}`;
const getLegacyTitleCacheKey = (title: string, type: 'movie' | 'series') =>
`${MAPPING_PREFIX}${title.trim()}_${type}`;
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, episode: number = 1, releaseDate?: string, dayIndex?: number, tmdbId?: number): Promise<number | null> => {
// Safety check: Never perform a MAL search for generic placeholders or empty strings.
// This prevents "cache poisoning" where a generic term matches a random anime.
const cleanTitle = title.trim();
const normalizedTitle = cleanTitle.toLowerCase();
const isGenericTitle = !normalizedTitle || normalizedTitle === 'anime' || normalizedTitle === 'movie';
const seasonNumber = season || 1;
const cacheKey = getTitleCacheKey(cleanTitle, type, seasonNumber);
const legacyCacheKey = getLegacyTitleCacheKey(cleanTitle, type);
const cachedId = mmkvStorage.getNumber(cacheKey) || mmkvStorage.getNumber(legacyCacheKey);
if (cachedId) {
// Backfill to season-aware key for future lookups.
if (!mmkvStorage.getNumber(cacheKey)) {
mmkvStorage.setNumber(cacheKey, cachedId);
}
return cachedId;
}
if (isGenericTitle && !imdbId && !tmdbId) return null;
// 1. Try TMDB-based Resolution (High Accuracy)
if (tmdbId && releaseDate) {
try {
const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex);
if (tmdbResult && tmdbResult.malId) {
console.log(`[MalSync] Found TMDB match: ${tmdbId} (${releaseDate}) -> MAL ${tmdbResult.malId}`);
return tmdbResult.malId;
}
} catch (e) {
console.warn('[MalSync] TMDB Sync failed:', e);
}
}
// 2. Try ARM + Jikan Sync (IMDb fallback)
if (imdbId && type === 'series' && releaseDate) {
try {
const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate, dayIndex);
if (armResult && armResult.malId) {
console.log(`[MalSync] Found ARM match: ${imdbId} (${releaseDate}) -> MAL ${armResult.malId} Ep ${armResult.episode}`);
// Note: ArmSyncService returns the *absolute* episode number for MAL (e.g. 76)
// but our 'episode' arg is usually relative (e.g. 1).
// scrobbleEpisode uses the malId returned here, and potentially the episode number from ArmSync
// But getMalId just returns the ID.
// Ideally, scrobbleEpisode should call ArmSyncService directly to get both ID and correct Episode number.
// For now, we return the ID.
return armResult.malId;
}
} catch (e) {
console.warn('[MalSync] ARM Sync failed:', e);
}
}
// 2. Try IMDb ID mapping when it is likely to be accurate, or when title is generic.
if (imdbId && (type === 'movie' || seasonNumber <= 1 || isGenericTitle)) {
const idFromImdb = await MalSync.getMalIdFromImdb(imdbId);
if (idFromImdb) return idFromImdb;
}
// 3. Search MAL (Skip if generic title)
if (isGenericTitle) return null;
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}`;
} else if (type === 'series' && season === 0) {
// Improve Season 0 (Specials) lookup: "Attack on Titan Specials" or "Attack on Titan OVA"
// We search for both to find the most likely entry
searchQuery = `${cleanTitle} Specials`;
}
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 if (season === 0) {
// For Season 0, prioritize specials, ovas, and onas
candidates = candidates.filter(r => r.node.media_type === 'special' || r.node.media_type === 'ova' || r.node.media_type === 'ona');
if (candidates.length === 0) {
// If no specific special types found, fallback to anything containing "Special" or "OVA" in title
candidates = result.data.filter(r =>
r.node.title.toLowerCase().includes('special') ||
r.node.title.toLowerCase().includes('ova') ||
r.node.title.toLowerCase().includes('ona')
);
}
} 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);
mmkvStorage.setNumber(legacyCacheKey, 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,
releaseDate?: string,
providedMalId?: number, // Optional: skip lookup if already known
dayIndex?: number, // 0-based index of episode in a same-day release batch
tmdbId?: number
) => {
try {
// Requirement 9 & 10: Respect user settings and safety
const isEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true;
const isAutoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true;
if (!isEnabled || !isAutoUpdate || !MalAuth.isAuthenticated()) {
return;
}
let malId: number | null = providedMalId || null;
let finalEpisodeNumber = episodeNumber;
// Strategy 1: TMDB-based Resolution (High Accuracy for Specials)
if (!malId && tmdbId && releaseDate) {
const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex);
if (tmdbResult) {
malId = tmdbResult.malId;
finalEpisodeNumber = tmdbResult.episode;
console.log(`[MalSync] TMDB Resolved: ${animeTitle} -> MAL ${malId} Ep ${finalEpisodeNumber}`);
}
}
// Strategy 2: IMDb-based Resolution (Fallback)
if (!malId && imdbId && type === 'series' && releaseDate) {
const armResult = await ArmSyncService.resolveByDate(imdbId, releaseDate, dayIndex);
if (armResult) {
malId = armResult.malId;
finalEpisodeNumber = armResult.episode;
console.log(`[MalSync] ARM Resolved: ${animeTitle} -> MAL ${malId} Ep ${finalEpisodeNumber}`);
}
}
// Fallback to standard lookup if ARM/TMDB failed and no ID provided
if (!malId) {
malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber, releaseDate, dayIndex, tmdbId);
}
if (!malId) return;
// Check current status on MAL to avoid overwriting completed/dropped shows
try {
const currentInfo = await MalApiService.getMyListStatus(malId);
const currentStatus = currentInfo.my_list_status?.status;
const currentEpisodesWatched = currentInfo.my_list_status?.num_episodes_watched || 0;
// Requirement 4: Auto-Add Anime to MAL (Configurable)
if (!currentStatus) {
const autoAdd = mmkvStorage.getBoolean('mal_auto_add') ?? true;
if (!autoAdd) {
console.log(`[MalSync] Skipping scrobble for ${animeTitle}: Not in list and auto-add disabled`);
return;
}
}
// If already completed or dropped, don't auto-update via scrobble
if (currentStatus === 'completed' || currentStatus === 'dropped') {
console.log(`[MalSync] Skipping update for ${animeTitle}: Status is ${currentStatus}`);
return;
}
// If we are just starting (ep 1) or resuming (plan_to_watch/on_hold/null), set to watching
// Also ensure we don't downgrade episode count (though unlikely with scrobbling forward)
if (finalEpisodeNumber <= currentEpisodesWatched) {
console.log(`[MalSync] Skipping update for ${animeTitle}: Episode ${finalEpisodeNumber} <= Current ${currentEpisodesWatched}`);
return;
}
} catch (e) {
// If error (e.g. not found), proceed to add it
}
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 && finalEpisodeNumber >= finalTotalEpisodes) {
status = 'completed';
}
await MalApiService.updateStatus(malId, status, finalEpisodeNumber);
console.log(`[MalSync] Synced ${animeTitle} Ep ${finalEpisodeNumber}/${finalTotalEpisodes || '?'} -> MAL ID ${malId} (${status})`);
} catch (e) {
console.error('[MalSync] Scrobble failed:', e);
}
},
/**
* Direct scrobble with known MAL ID and Episode
* Used when ArmSync has already resolved the exact details.
*/
scrobbleDirect: async (malId: number, episodeNumber: number) => {
try {
// Respect user settings and login status
const isEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true;
const isAutoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true;
if (!isEnabled || !isAutoUpdate || !MalAuth.isAuthenticated()) return;
// Check current status
const currentInfo = await MalApiService.getMyListStatus(malId);
const currentStatus = currentInfo.my_list_status?.status;
// Auto-Add check
if (!currentStatus) {
const autoAdd = mmkvStorage.getBoolean('mal_auto_add') ?? true;
if (!autoAdd) {
console.log(`[MalSync] Skipping direct scrobble: Not in list and auto-add disabled`);
return;
}
}
// Safety checks (Completed/Dropped/Regression)
if (currentStatus === 'completed' || currentStatus === 'dropped') return;
if (currentInfo.my_list_status?.num_episodes_watched && episodeNumber <= currentInfo.my_list_status.num_episodes_watched) return;
// Determine Status
let status: MalListStatus = 'watching';
if (currentInfo.num_episodes > 0 && episodeNumber >= currentInfo.num_episodes) {
status = 'completed';
}
await MalApiService.updateStatus(malId, status, episodeNumber);
console.log(`[MalSync] Direct synced MAL ID ${malId} Ep ${episodeNumber} (${status})`);
} catch (e) {
console.error('[MalSync] Direct scrobble failed:', e);
}
},
/**
* Import MAL list items into local library
*/
syncMalToLibrary: async () => {
if (!MalAuth.isAuthenticated()) return false;
try {
let allItems: MalAnimeNode[] = [];
let offset = 0;
let hasMore = true;
while (hasMore && offset < 1000) { // Limit to 1000 items for safety
const response = await MalApiService.getUserList(undefined, offset, 100);
if (response.data && response.data.length > 0) {
allItems = [...allItems, ...response.data];
offset += response.data.length;
hasMore = !!response.paging.next;
} else {
hasMore = false;
}
}
for (const item of allItems) {
const type = item.node.media_type === 'movie' ? 'movie' : 'series';
const title = item.node.title.trim();
mmkvStorage.setNumber(getTitleCacheKey(title, type, 1), item.node.id);
// Keep legacy key for backwards compatibility with old cache readers.
mmkvStorage.setNumber(getLegacyTitleCacheKey(title, type), item.node.id);
}
console.log(`[MalSync] Synced ${allItems.length} items to mapping cache.`);
// If auto-sync to library is enabled, also add 'watching' items to Nuvio Library
if (mmkvStorage.getBoolean('mal_auto_sync_to_library') ?? false) {
await MalSync.syncMalWatchingToLibrary();
}
return true;
} catch (e) {
console.error('syncMalToLibrary failed', e);
return false;
}
},
/**
* Automatically adds MAL 'watching' items to the Nuvio Library
*/
syncMalWatchingToLibrary: async () => {
if (!MalAuth.isAuthenticated()) return;
try {
logger.log('[MalSync] Auto-syncing MAL watching items to library...');
const response = await MalApiService.getUserList('watching', 0, 50);
if (!response.data || response.data.length === 0) return;
const currentLibrary = await catalogService.getLibraryItems();
const libraryIds = new Set(currentLibrary.map(l => l.id));
// Process items in small batches to avoid rate limiting
for (let i = 0; i < response.data.length; i += 5) {
const batch = response.data.slice(i, i + 5);
await Promise.all(batch.map(async (item) => {
const malId = item.node.id;
const { imdbId } = await MalSync.getIdsFromMalId(malId);
if (imdbId && !libraryIds.has(imdbId)) {
const type = item.node.media_type === 'movie' ? 'movie' : 'series';
logger.log(`[MalSync] Auto-adding to library: ${item.node.title} (${imdbId})`);
await catalogService.addToLibrary({
id: imdbId,
type: type,
name: item.node.title,
poster: item.node.main_picture?.large || item.node.main_picture?.medium || '',
posterShape: 'poster',
year: item.node.start_season?.year,
description: '',
genres: [],
inLibrary: true,
});
}
}));
}
} catch (e) {
logger.error('[MalSync] syncMalWatchingToLibrary failed:', e);
}
},
/**
* Manually map an ID if auto-detection fails
*/
setMapping: (title: string, malId: number, type: 'movie' | 'series' = 'series', season: number = 1) => {
const cleanTitle = title.trim();
mmkvStorage.setNumber(getTitleCacheKey(cleanTitle, type, season), malId);
// Keep legacy key for compatibility.
mmkvStorage.setNumber(getLegacyTitleCacheKey(cleanTitle, type), malId);
},
/**
* Get external IDs (IMDb, etc.) and season info from a MAL ID using MalSync API
*/
getIdsFromMalId: async (malId: number): Promise<{ imdbId: string | null; season: number }> => {
const cacheKey = `mal_ext_ids_v2_${malId}`;
const cached = mmkvStorage.getString(cacheKey);
if (cached) {
return JSON.parse(cached);
}
try {
const response = await axios.get(`https://api.malsync.moe/mal/anime/${malId}`);
const data = response.data;
let imdbId = null;
let season = data.season || 1;
// Try to find IMDb ID in Sites
if (data.Sites && data.Sites.IMDB) {
const imdbKeys = Object.keys(data.Sites.IMDB);
if (imdbKeys.length > 0) {
imdbId = imdbKeys[0];
}
}
const result = { imdbId, season };
mmkvStorage.setString(cacheKey, JSON.stringify(result));
return result;
} catch (e) {
console.error('[MalSync] Failed to fetch external IDs:', e);
}
return { imdbId: null, season: 1 };
},
/**
* Get weekly anime schedule from Jikan API (Adjusted to Local Timezone)
*/
getWeeklySchedule: async (): Promise<any[]> => {
const cacheKey = 'mal_weekly_schedule_local_v2'; // Bump version for new format
const cached = mmkvStorage.getString(cacheKey);
const cacheTime = mmkvStorage.getNumber(`${cacheKey}_time`);
// Cache for 24 hours
if (cached && cacheTime && (Date.now() - cacheTime < 24 * 60 * 60 * 1000)) {
return JSON.parse(cached);
}
try {
// Jikan API rate limit mitigation
await new Promise(resolve => setTimeout(resolve, 500));
const response = await axios.get('https://api.jikan.moe/v4/schedules');
const data = response.data.data;
const daysOrder = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'];
const dayMap: Record<string, number> = { 'Mondays': 0, 'Tuesdays': 1, 'Wednesdays': 2, 'Thursdays': 3, 'Fridays': 4, 'Saturdays': 5, 'Sundays': 6 };
const daysReverse = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'];
const grouped: Record<string, any[]> = {};
// Calculate time difference in minutes: Local - JST (UTC+9)
// getTimezoneOffset() returns minutes BEHIND UTC (positive for US, negative for Asia)
// We want Local - UTC+9.
// Local = UTC - offset.
// Diff = (UTC - localOffset) - (UTC + 540) = -localOffset - 540.
const jstOffset = 540; // UTC+9 in minutes
const localOffset = new Date().getTimezoneOffset(); // e.g. 300 for EST (UTC-5)
const offsetMinutes = -localOffset - jstOffset; // e.g. -300 - 540 = -840 minutes (-14h)
data.forEach((anime: any) => {
let day = anime.broadcast?.day; // "Mondays"
let time = anime.broadcast?.time; // "23:00"
let originalDay = day;
// Adjust to local time
if (day && time && dayMap[day] !== undefined) {
const [hours, mins] = time.split(':').map(Number);
let totalMinutes = hours * 60 + mins + offsetMinutes;
let dayShift = 0;
// Handle day rollovers
if (totalMinutes < 0) {
totalMinutes += 24 * 60;
dayShift = -1;
} else if (totalMinutes >= 24 * 60) {
totalMinutes -= 24 * 60;
dayShift = 1;
}
const newHour = Math.floor(totalMinutes / 60);
const newMin = totalMinutes % 60;
time = `${String(newHour).padStart(2,'0')}:${String(newMin).padStart(2,'0')}`;
let dayIndex = dayMap[day] + dayShift;
if (dayIndex < 0) dayIndex = 6;
if (dayIndex > 6) dayIndex = 0;
day = daysReverse[dayIndex];
} else {
day = 'Other'; // No specific time/day
}
if (!grouped[day]) grouped[day] = [];
grouped[day].push({
id: `mal:${anime.mal_id}`,
seriesId: `mal:${anime.mal_id}`,
title: anime.title,
seriesName: anime.title_english || anime.title,
poster: anime.images?.jpg?.large_image_url || anime.images?.jpg?.image_url,
releaseDate: null,
season: 1,
episode: 1,
overview: anime.synopsis,
vote_average: anime.score,
day: day,
time: time,
genres: anime.genres?.map((g: any) => g.name) || [],
originalDay: originalDay // Keep for debug if needed
});
});
// Sort by day (starting Monday or Today?) -> Standard is Monday start for anime
// Sort items by time within day
const result = [...daysOrder, 'Other']
.filter(day => grouped[day] && grouped[day].length > 0)
.map(day => ({
title: day,
data: grouped[day].sort((a, b) => (a.time || '99:99').localeCompare(b.time || '99:99'))
}));
mmkvStorage.setString(cacheKey, JSON.stringify(result));
mmkvStorage.setNumber(`${cacheKey}_time`, Date.now());
return result;
} catch (e) {
console.error('[MalSync] Failed to fetch schedule:', e);
return [];
}
}
};

View file

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

View file

@ -1270,29 +1270,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 };
@ -1448,8 +1494,9 @@ class LocalScraperService {
// 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
@ -1462,7 +1509,6 @@ class LocalScraperService {
'CryptoJS',
'cheerio',
'logger',
'console',
'params',
'PRIMARY_KEY',
'TMDB_API_KEY',
@ -1477,6 +1523,9 @@ class LocalScraperService {
globalScope.TMDB_API_KEY = TMDB_API_KEY;
globalScope.SCRAPER_SETTINGS = SCRAPER_SETTINGS;
globalScope.SCRAPER_ID = SCRAPER_ID;
if (typeof URL_VALIDATION_ENABLED !== 'undefined') {
globalScope.URL_VALIDATION_ENABLED = URL_VALIDATION_ENABLED;
}
} else {
logger.error('[Plugin Sandbox] Could not find global scope to inject settings');
}
@ -1484,15 +1533,16 @@ class LocalScraperService {
// 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;
}
`
);
@ -1506,8 +1556,7 @@ class LocalScraperService {
polyfilledFetch, // Use polyfilled fetch for redirect: manual support
CryptoJS,
cheerio,
logger,
consoleOverride || console, // Expose console (or override) to plugins for debugging
customLogger,
params,
MOVIEBOX_PRIMARY_KEY,
MOVIEBOX_TMDB_API_KEY,
@ -1519,7 +1568,7 @@ class LocalScraperService {
if (result && typeof result.then === 'function') {
result.then(resolve).catch(reject);
} else {
resolve(result || []);
resolve(result);
}
} catch (error) {
reject(error);
@ -1535,11 +1584,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)) {
@ -1722,73 +1840,6 @@ class LocalScraperService {
}
}
// Test a plugin independently with log capturing.
// If onLog is provided, each formatted log line is emitted as it happens.
async testPlugin(
code: string,
params: { tmdbId: string; mediaType: string; season?: number; episode?: number },
options?: { onLog?: (line: string) => void }
): Promise<{ streams: Stream[]; logs: string[] }> {
const logs: string[] = [];
const emit = (line: string) => {
logs.push(line);
options?.onLog?.(line);
};
// Create a console proxy to capture logs
const consoleProxy = {
log: (...args: any[]) => {
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
emit(`[LOG] ${msg}`);
console.log('[PluginTest]', msg);
},
error: (...args: any[]) => {
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
emit(`[ERROR] ${msg}`);
console.error('[PluginTest]', msg);
},
warn: (...args: any[]) => {
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
emit(`[WARN] ${msg}`);
console.warn('[PluginTest]', msg);
},
info: (...args: any[]) => {
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
emit(`[INFO] ${msg}`);
console.info('[PluginTest]', msg);
},
debug: (...args: any[]) => {
const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
emit(`[DEBUG] ${msg}`);
console.debug('[PluginTest]', msg);
}
};
try {
const results = await this.executePlugin(code, params, consoleProxy);
// Convert results using a dummy scraper info since we don't have one for ad-hoc tests
const dummyScraperInfo: ScraperInfo = {
id: 'test-plugin',
name: 'Test Plugin',
version: '1.0.0',
description: 'Test',
filename: 'test.js',
supportedTypes: ['movie', 'tv'],
enabled: true
};
const streams = this.convertToStreams(results, dummyScraperInfo);
return { streams, logs };
} catch (error: any) {
emit(`[FATAL ERROR] ${error.message || String(error)}`);
if (error.stack) {
emit(`[STACK] ${error.stack}`);
}
return { streams: [], logs };
}
}
}
export const localScraperService = LocalScraperService.getInstance();

View file

@ -5,6 +5,7 @@ import EventEmitter from 'eventemitter3';
import { localScraperService } from './pluginService';
import { DEFAULT_SETTINGS, AppSettings } from '../hooks/useSettings';
import { TMDBService } from './tmdbService';
import { MalSync } from './mal/MalSync';
import { safeAxiosConfig, createSafeAxiosConfig } from '../utils/axiosConfig';
// Create an event emitter for addon changes
@ -1257,6 +1258,9 @@ class StremioService {
async getStreams(type: string, id: string, callback?: StreamCallback): Promise<void> {
await this.ensureInitialized();
let activeId = id;
let resolvedTmdbId: string | null = null;
const addons = this.getInstalledAddons();
// Some addons use non-standard meta types (e.g. "anime") but expect streams under the "series" endpoint.
@ -1320,7 +1324,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';
@ -1566,7 +1570,7 @@ class StremioService {
}
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
const encodedId = encodeURIComponent(id);
const encodedId = encodeURIComponent(activeId);
const url = queryParams ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`;
logger.log(
@ -2058,8 +2062,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) {
@ -2073,12 +2075,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 {
@ -2088,14 +2090,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;
}

View file

@ -3,6 +3,9 @@ import { SimklService } from './simklService';
import { storageService } from './storageService';
import { mmkvStorage } from './mmkvStorage';
import { logger } from '../utils/logger';
import { MalSync } from './mal/MalSync';
import { MalAuth } from './mal/MalAuth';
import { ArmSyncService } from './mal/ArmSyncService';
export interface LocalWatchedItem {
content_id: string;
@ -15,10 +18,10 @@ export interface LocalWatchedItem {
/**
* WatchedService - Manages "watched" status for movies, episodes, and seasons.
* Handles both local storage and Trakt sync transparently.
*
* When Trakt is authenticated, it syncs to Trakt.
* When not authenticated, it stores locally.
* Handles both local storage and Trakt/Simkl/MAL sync transparently.
*
* When a service is authenticated, it syncs to that service.
* Always stores locally for offline access and fallback.
*/
class WatchedService {
private static instance: WatchedService;
@ -198,21 +201,39 @@ class WatchedService {
*/
public async markMovieAsWatched(
imdbId: string,
watchedAt: Date = new Date()
watchedAt: Date = new Date(),
malId?: number,
tmdbId?: number,
title?: string
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`);
// Check if Trakt is authenticated
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
// Sync to Trakt
if (isTraktAuth) {
// Sync to Trakt
syncedToTrakt = await this.traktService.addToWatchedMovies(imdbId, watchedAt);
logger.log(`[WatchedService] Trakt sync result for movie: ${syncedToTrakt}`);
}
// Sync to MAL
if (MalAuth.isAuthenticated()) {
MalSync.scrobbleEpisode(
title || 'Movie', // Use real title or generic fallback
1,
1,
'movie',
undefined,
imdbId,
undefined,
malId,
undefined,
tmdbId
).catch(err => logger.error('[WatchedService] MAL movie sync failed:', err));
}
// Sync to Simkl
const isSimklAuth = await this.simklService.isAuthenticated();
if (isSimklAuth) {
@ -253,17 +274,21 @@ class WatchedService {
showId: string,
season: number,
episode: number,
watchedAt: Date = new Date()
watchedAt: Date = new Date(),
releaseDate?: string, // Optional release date for precise matching
showTitle?: string,
malId?: number,
dayIndex?: number,
tmdbId?: number
): Promise<{ success: boolean; syncedToTrakt: boolean }> {
try {
logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`);
// Check if Trakt is authenticated
const isTraktAuth = await this.traktService.isAuthenticated();
let syncedToTrakt = false;
// Sync to Trakt
if (isTraktAuth) {
// Sync to Trakt
syncedToTrakt = await this.traktService.addToWatchedEpisodes(
showImdbId,
season,
@ -273,6 +298,58 @@ class WatchedService {
logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`);
}
// Sync to MAL
if (MalAuth.isAuthenticated() && (showImdbId || malId || tmdbId)) {
// Strategy 0: Direct Match (if malId is provided)
let synced = false;
if (malId) {
await MalSync.scrobbleDirect(malId, episode);
synced = true;
}
// Strategy 1: TMDB-based Resolution (High Accuracy for Specials)
if (!synced && releaseDate && tmdbId) {
try {
const tmdbResult = await ArmSyncService.resolveByTmdb(tmdbId, releaseDate, dayIndex);
if (tmdbResult) {
await MalSync.scrobbleDirect(tmdbResult.malId, tmdbResult.episode);
synced = true;
}
} catch (e) {
logger.warn('[WatchedService] TMDB Sync failed, falling back to IMDb:', e);
}
}
// Strategy 2: IMDb-based Resolution (Fallback)
if (!synced && releaseDate && showImdbId) {
try {
const armResult = await ArmSyncService.resolveByDate(showImdbId, releaseDate, dayIndex);
if (armResult) {
await MalSync.scrobbleDirect(armResult.malId, armResult.episode);
synced = true;
}
} catch (e) {
logger.warn('[WatchedService] ARM Sync failed, falling back to offline map:', e);
}
}
// Strategy 3: Offline Mapping / Search Fallback
if (!synced) {
MalSync.scrobbleEpisode(
showTitle || showImdbId || 'Anime',
episode,
0,
'series',
season,
showImdbId,
releaseDate,
malId,
dayIndex,
tmdbId
).catch(err => logger.error('[WatchedService] MAL sync failed:', err));
}
}
// Sync to Simkl
const isSimklAuth = await this.simklService.isAuthenticated();
if (isSimklAuth) {

23
src/types/calendar.ts Normal file
View file

@ -0,0 +1,23 @@
export interface CalendarEpisode {
id: string;
seriesId: string;
title: string;
seriesName: string;
poster: 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[];
}
export interface CalendarSection {
title: string;
data: CalendarEpisode[];
}

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

@ -0,0 +1,80 @@
export interface MalToken {
accessToken: string;
refreshToken: string;
expiresIn: number; // Seconds
createdAt: number; // Timestamp
}
export interface MalUser {
id: number;
name: string;
picture?: string;
gender?: string;
birthday?: string;
location?: string;
joined_at?: string;
time_zone?: string;
anime_statistics?: {
num_items_watching: number;
num_items_completed: number;
num_items_on_hold: number;
num_items_dropped: number;
num_items_plan_to_watch: number;
num_items: number;
num_days_watched: number;
num_days_watching: number;
num_days_completed: number;
num_days_on_hold: number;
num_days_dropped: number;
num_days: number;
num_episodes: number;
num_times_rewatched: number;
mean_score: number;
};
}
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;
};
}