From 2417bf548a4f60463a61b5b8ed48905f5f247e79 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:55:58 +0530 Subject: [PATCH] feat: added theintrodb.org skip intro service --- src/components/player/AndroidVideoPlayer.tsx | 9 +- src/components/player/KSPlayerCore.tsx | 9 +- src/components/player/common/UpNextButton.tsx | 16 +- .../player/overlays/SkipIntroButton.tsx | 35 ++-- src/hooks/useSettings.ts | 2 + .../settings/PlaybackSettingsScreen.tsx | 144 ++++++++++++++++- src/services/introService.ts | 149 ++++++++++++++++-- 7 files changed, 331 insertions(+), 33 deletions(-) diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index c757b8d7..ed785869 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -44,6 +44,7 @@ import ParentalGuideOverlay from './overlays/ParentalGuideOverlay'; import SkipIntroButton from './overlays/SkipIntroButton'; import UpNextButton from './common/UpNextButton'; import { CustomAlert } from '../CustomAlert'; +import { CreditsInfo } from '../../services/introService'; // Android-specific components @@ -144,6 +145,9 @@ const AndroidVideoPlayer: React.FC = () => { // Subtitle sync modal state const [showSyncModal, setShowSyncModal] = useState(false); + // Credits timing state from API + const [creditsInfo, setCreditsInfo] = useState(null); + // Track auto-selection ref to prevent duplicate selections const hasAutoSelectedTracks = useRef(false); @@ -167,7 +171,7 @@ const AndroidVideoPlayer: React.FC = () => { }, [uri, episodeId]); const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) }); - const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] }; + const { metadata, cast, tmdbId } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [], tmdbId: null }; const hasLogo = metadata && metadata.logo; const openingAnimation = useOpeningAnimation(backdrop, metadata); @@ -961,8 +965,10 @@ 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} + tmdbId={tmdbId || undefined} currentTime={playerState.currentTime} onSkip={(endTime) => controlsHook.seekToTime(endTime)} + onCreditsInfo={setCreditsInfo} controlsVisible={playerState.showControls} controlsFixedOffset={100} /> @@ -988,6 +994,7 @@ const AndroidVideoPlayer: React.FC = () => { metadata={metadataResult?.metadata ? { poster: metadataResult.metadata.poster, id: metadataResult.metadata.id } : undefined} controlsVisible={playerState.showControls} controlsFixedOffset={100} + creditsInfo={creditsInfo} /> diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 4d042109..737daa5f 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -21,6 +21,7 @@ import ResumeOverlay from './modals/ResumeOverlay'; import ParentalGuideOverlay from './overlays/ParentalGuideOverlay'; import SkipIntroButton from './overlays/SkipIntroButton'; import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components'; +import { CreditsInfo } from '../../services/introService'; // Platform-specific components import { KSPlayerSurface } from './ios/components/KSPlayerSurface'; @@ -154,7 +155,7 @@ const KSPlayerCore: React.FC = () => { const speedControl = useSpeedControl(1.0); // Metadata Hook - const { metadata, groupedEpisodes, cast } = useMetadata({ id, type: type as 'movie' | 'series' }); + const { metadata, groupedEpisodes, cast, tmdbId } = useMetadata({ id, type: type as 'movie' | 'series' }); // Trakt Autosync const traktAutosync = useTraktAutosync({ @@ -177,6 +178,9 @@ const KSPlayerCore: React.FC = () => { // Subtitle sync modal state const [showSyncModal, setShowSyncModal] = useState(false); + // Credits timing state from API + const [creditsInfo, setCreditsInfo] = useState(null); + // Track auto-selection refs to prevent duplicate selections const hasAutoSelectedTracks = useRef(false); @@ -923,8 +927,10 @@ 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} + tmdbId={tmdbId || undefined} currentTime={currentTime} onSkip={(endTime) => controls.seekToTime(endTime)} + onCreditsInfo={setCreditsInfo} controlsVisible={showControls} controlsFixedOffset={126} /> @@ -950,6 +956,7 @@ const KSPlayerCore: React.FC = () => { metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined} controlsVisible={showControls} controlsFixedOffset={126} + creditsInfo={creditsInfo} /> {/* Modals */} diff --git a/src/components/player/common/UpNextButton.tsx b/src/components/player/common/UpNextButton.tsx index 9958e0c6..d8c5de0b 100644 --- a/src/components/player/common/UpNextButton.tsx +++ b/src/components/player/common/UpNextButton.tsx @@ -4,6 +4,7 @@ import { Animated } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { logger } from '../../../utils/logger'; import { LinearGradient } from 'expo-linear-gradient'; +import { CreditsInfo } from '../../../services/introService'; export interface Insets { top: number; @@ -33,6 +34,7 @@ interface UpNextButtonProps { metadata?: { poster?: string; id?: string }; // Added metadata prop controlsVisible?: boolean; controlsFixedOffset?: number; + creditsInfo?: CreditsInfo | null; // Add credits info from API } const UpNextButton: React.FC = ({ @@ -49,6 +51,7 @@ const UpNextButton: React.FC = ({ metadata, controlsVisible = false, controlsFixedOffset = 100, + creditsInfo, }) => { const [visible, setVisible] = useState(false); const opacity = useRef(new Animated.Value(0)).current; @@ -76,10 +79,19 @@ const UpNextButton: React.FC = ({ const shouldShow = useMemo(() => { if (!nextEpisode || duration <= 0) return false; + + // If we have credits timing from API, use that as primary source + if (creditsInfo?.startTime !== null && creditsInfo?.startTime !== undefined) { + // Show button when we reach credits start time and stay visible until 10s before end + const timeRemaining = duration - currentTime; + const isInCredits = currentTime >= creditsInfo.startTime; + return isInCredits && timeRemaining > 10; + } + + // Fallback: Use fixed timing (show when under ~1 minute and above 10s) const timeRemaining = duration - currentTime; - // Be tolerant to timer jitter: show when under ~1 minute and above 10s return timeRemaining < 61 && timeRemaining > 10; - }, [nextEpisode, duration, currentTime]); + }, [nextEpisode, duration, currentTime, creditsInfo]); // Debug logging removed to reduce console noise // The state is computed in shouldShow useMemo above diff --git a/src/components/player/overlays/SkipIntroButton.tsx b/src/components/player/overlays/SkipIntroButton.tsx index b5329c6a..2061aab7 100644 --- a/src/components/player/overlays/SkipIntroButton.tsx +++ b/src/components/player/overlays/SkipIntroButton.tsx @@ -10,7 +10,7 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialIcons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; -import { introService, SkipInterval, SkipType } from '../../../services/introService'; +import { introService, SkipInterval, SkipType, CreditsInfo } from '../../../services/introService'; import { useTheme } from '../../../contexts/ThemeContext'; import { logger } from '../../../utils/logger'; import { useSettings } from '../../../hooks/useSettings'; @@ -22,8 +22,10 @@ interface SkipIntroButtonProps { episode?: number; malId?: string; kitsuId?: string; + tmdbId?: number; currentTime: number; onSkip: (endTime: number) => void; + onCreditsInfo?: (credits: CreditsInfo | null) => void; controlsVisible?: boolean; controlsFixedOffset?: number; } @@ -35,8 +37,10 @@ export const SkipIntroButton: React.FC = ({ episode, malId, kitsuId, + tmdbId, currentTime, onSkip, + onCreditsInfo, controlsVisible = false, controlsFixedOffset = 100, }) => { @@ -65,20 +69,22 @@ export const SkipIntroButton: React.FC = ({ // Fetch skip data when episode changes useEffect(() => { - const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`; + const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${tmdbId}`; if (!skipIntroEnabled) { setSkipIntervals([]); setCurrentInterval(null); setIsVisible(false); fetchedRef.current = false; + if (onCreditsInfo) onCreditsInfo(null); return; } // Skip if not a series or missing required data (though MAL/Kitsu ID might be enough for some cases, usually need season/ep) - if (type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) { + if (type !== 'series' || (!imdbId && !malId && !kitsuId && !tmdbId) || !season || !episode) { setSkipIntervals([]); fetchedRef.current = false; + if (onCreditsInfo) onCreditsInfo(null); return; } @@ -94,24 +100,35 @@ export const SkipIntroButton: React.FC = ({ setSkipIntervals([]); const fetchSkipData = async () => { - logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`); + logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (TMDB: ${tmdbId}, IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`); try { - const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId); - setSkipIntervals(intervals); + const mediaType = type === 'series' ? 'tv' : type === 'movie' ? 'movie' : 'tv'; + const result = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, tmdbId, mediaType); + setSkipIntervals(result.intervals); - if (intervals.length > 0) { - logger.log(`[SkipIntroButton] ✓ Found ${intervals.length} skip intervals:`, intervals); + // Pass credits info to parent via callback + if (onCreditsInfo) { + onCreditsInfo(result.credits); + } + + if (result.intervals.length > 0) { + logger.log(`[SkipIntroButton] ✓ Found ${result.intervals.length} skip intervals:`, result.intervals); } else { logger.log(`[SkipIntroButton] ✗ No skip data available for this episode`); } + + if (result.credits) { + logger.log(`[SkipIntroButton] ✓ Found credits timing:`, result.credits); + } } catch (error) { logger.error('[SkipIntroButton] Error fetching skip data:', error); setSkipIntervals([]); + if (onCreditsInfo) onCreditsInfo(null); } }; fetchSkipData(); - }, [imdbId, type, season, episode, malId, kitsuId, skipIntroEnabled]); + }, [imdbId, type, season, episode, malId, kitsuId, tmdbId, skipIntroEnabled, onCreditsInfo]); // Determine active interval based on current playback position useEffect(() => { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index c285a1de..838afb94 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -59,6 +59,7 @@ export interface AppSettings { // Playback behavior alwaysResume: boolean; // If true, resume automatically without prompt when progress < 85% skipIntroEnabled: boolean; // Enable/disable Skip Intro overlay (IntroDB) + introDbSource: 'theintrodb' | 'introdb'; // Preferred IntroDB source: TheIntroDB (new) or IntroDB (legacy) // Downloads enableDownloads: boolean; // Show Downloads tab and enable saving streams // Theme settings @@ -147,6 +148,7 @@ export const DEFAULT_SETTINGS: AppSettings = { // Playback behavior defaults alwaysResume: true, skipIntroEnabled: true, + introDbSource: 'theintrodb', // Default to TheIntroDB (new API) // Downloads enableDownloads: false, useExternalPlayerForDownloads: false, diff --git a/src/screens/settings/PlaybackSettingsScreen.tsx b/src/screens/settings/PlaybackSettingsScreen.tsx index 8cd69710..00b3f998 100644 --- a/src/screens/settings/PlaybackSettingsScreen.tsx +++ b/src/screens/settings/PlaybackSettingsScreen.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { View, StyleSheet, ScrollView, StatusBar, Platform, Text, TouchableOpacity, Dimensions } from 'react-native'; +import { View, StyleSheet, ScrollView, StatusBar, Platform, Text, TouchableOpacity, Dimensions, Image } from 'react-native'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -17,6 +17,7 @@ import { SvgXml } from 'react-native-svg'; const { width } = Dimensions.get('window'); const INTRODB_LOGO_URI = 'https://introdb.app/images/logo-vector.svg'; +const THEINTRODB_FAVICON_URI = 'https://theintrodb.org/favicon.ico'; // Available languages for audio/subtitle selection const AVAILABLE_LANGUAGES = [ @@ -77,6 +78,7 @@ export const PlaybackSettingsContent: React.FC = ( const config = useRealtimeConfig(); const [introDbLogoXml, setIntroDbLogoXml] = useState(null); + const [theIntroDbLoaded, setTheIntroDbLoaded] = useState(false); useEffect(() => { let cancelled = false; @@ -103,20 +105,57 @@ export const PlaybackSettingsContent: React.FC = ( }; }, []); - const introDbLogoIcon = introDbLogoXml ? ( - - ) : ( - - ); + // Preload TheIntroDB favicon + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + await fetch(THEINTRODB_FAVICON_URI); + if (!cancelled) setTheIntroDbLoaded(true); + } catch { + if (!cancelled) setTheIntroDbLoaded(false); + } + }; + load(); + return () => { + cancelled = true; + }; + }, []); + + const introDbLogoIcon = useMemo(() => { + const selectedSource = settings?.introDbSource || 'theintrodb'; + + if (selectedSource === 'theintrodb') { + // Show TheIntroDB favicon + return theIntroDbLoaded ? ( + + ) : ( + + ); + } else { + // Show IntroDB logo (legacy) + return introDbLogoXml ? ( + + ) : ( + + ); + } + }, [settings?.introDbSource, introDbLogoXml, theIntroDbLoaded, currentTheme.colors.primary]); // Bottom sheet refs const audioLanguageSheetRef = useRef(null); const subtitleLanguageSheetRef = useRef(null); const subtitleSourceSheetRef = useRef(null); + const introSourceSheetRef = useRef(null); // Snap points const languageSnapPoints = useMemo(() => ['70%'], []); const sourceSnapPoints = useMemo(() => ['45%'], []); + const introSourceSnapPoints = useMemo(() => ['35%'], []); // Handlers to present sheets - ensure only one is open at a time const openAudioLanguageSheet = useCallback(() => { @@ -137,6 +176,13 @@ export const PlaybackSettingsContent: React.FC = ( setTimeout(() => subtitleSourceSheetRef.current?.present(), 100); }, []); + const openIntroSourceSheet = useCallback(() => { + audioLanguageSheetRef.current?.dismiss(); + subtitleLanguageSheetRef.current?.dismiss(); + subtitleSourceSheetRef.current?.dismiss(); + setTimeout(() => introSourceSheetRef.current?.present(), 100); + }, []); + const isItemVisible = (itemId: string) => { if (!config?.items) return true; const item = config.items[itemId]; @@ -188,6 +234,17 @@ export const PlaybackSettingsContent: React.FC = ( subtitleSourceSheetRef.current?.dismiss(); }; + const handleSelectIntroSource = (value: 'theintrodb' | 'introdb') => { + updateSetting('introDbSource', value); + introSourceSheetRef.current?.dismiss(); + }; + + const getIntroSourceLabel = (value: string) => { + if (value === 'theintrodb') return 'TheIntroDB'; + if (value === 'introdb') return 'IntroDB'; + return 'TheIntroDB'; + }; + return ( <> {hasVisibleItems(['video_player']) && ( @@ -212,7 +269,7 @@ export const PlaybackSettingsContent: React.FC = ( ( = ( onValueChange={(value) => updateSetting('skipIntroEnabled', value)} /> )} - isLast isTablet={isTablet} /> + {settings?.skipIntroEnabled && ( + } + onPress={openIntroSourceSheet} + isLast + isTablet={isTablet} + /> + )} {/* Audio & Subtitle Preferences */} @@ -442,6 +509,67 @@ export const PlaybackSettingsContent: React.FC = ( })} + + {/* Intro Source Bottom Sheet */} + + + Skip Intro Source + + + {[ + { value: 'theintrodb', label: 'TheIntroDB', description: 'theintrodb.org - Supports skip recap and end credits if available', logo: THEINTRODB_FAVICON_URI }, + { value: 'introdb', label: 'IntroDB', description: 'Skip Intro database by introdb.app', logo: INTRODB_LOGO_URI } + ].map((option) => { + const isSelected = option.value === (settings?.introDbSource || 'theintrodb'); + return ( + handleSelectIntroSource(option.value as 'theintrodb' | 'introdb')} + > + + + {option.value === 'theintrodb' ? ( + + ) : ( + introDbLogoXml ? ( + + ) : ( + + ) + )} + + {option.label} + + + + {option.description} + + + {isSelected && ( + + )} + + ); + })} + + ); }; diff --git a/src/services/introService.ts b/src/services/introService.ts index 9d225903..1cce4ee1 100644 --- a/src/services/introService.ts +++ b/src/services/introService.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import { logger } from '../utils/logger'; import { tmdbService } from './tmdbService'; +import { mmkvStorage } from './mmkvStorage'; /** * IntroDB API service for fetching TV show intro timestamps @@ -8,6 +9,7 @@ import { tmdbService } from './tmdbService'; */ const INTRODB_API_URL = process.env.EXPO_PUBLIC_INTRODB_API_URL; +const THEINTRODB_API_URL = 'https://api.theintrodb.org/v1'; const ANISKIP_API_URL = 'https://api.aniskip.com/v2'; const KITSU_API_URL = 'https://kitsu.io/api/edge'; const ARM_IMDB_URL = 'https://arm.haglund.dev/api/v2/imdb'; @@ -18,10 +20,31 @@ export interface SkipInterval { startTime: number; endTime: number; type: SkipType; - provider: 'introdb' | 'aniskip'; + provider: 'introdb' | 'aniskip' | 'theintrodb'; skipId?: string; } +export interface CreditsInfo { + startTime: number | null; + endTime: number | null; + confidence: number; +} + +export interface TheIntroDBTimestamp { + start_ms: number | null; + end_ms: number | null; + confidence: number; + submission_count: number; +} + +export interface TheIntroDBResponse { + tmdb_id: number; + type: 'movie' | 'tv'; + intro?: TheIntroDBTimestamp; + recap?: TheIntroDBTimestamp; + credits?: TheIntroDBTimestamp; +} + export interface IntroTimestamps { imdb_id: string; season: number; @@ -152,6 +175,75 @@ async function fetchFromAniSkip(malId: string, episode: number): Promise { + try { + const params: any = { tmdb_id: tmdbId }; + if (type === 'tv' && season !== undefined && episode !== undefined) { + params.season = season; + params.episode = episode; + } + + const response = await axios.get(`${THEINTRODB_API_URL}/media`, { + params, + timeout: 5000, + }); + + const intervals: SkipInterval[] = []; + let credits: CreditsInfo | null = null; + + // Add intro skip interval if available + if (response.data.intro && response.data.intro.end_ms !== null) { + intervals.push({ + startTime: response.data.intro.start_ms !== null ? response.data.intro.start_ms / 1000 : 0, + endTime: response.data.intro.end_ms / 1000, + type: 'intro', + provider: 'theintrodb' + }); + } + + // Add recap skip interval if available + if (response.data.recap && response.data.recap.start_ms !== null && response.data.recap.end_ms !== null) { + intervals.push({ + startTime: response.data.recap.start_ms / 1000, + endTime: response.data.recap.end_ms / 1000, + type: 'recap', + provider: 'theintrodb' + }); + } + + // Store credits info for next episode button timing + if (response.data.credits && response.data.credits.start_ms !== null) { + credits = { + startTime: response.data.credits.start_ms / 1000, + endTime: response.data.credits.end_ms !== null ? response.data.credits.end_ms / 1000 : null, + confidence: response.data.credits.confidence + }; + } + + if (intervals.length > 0 || credits) { + logger.log(`[IntroService] TheIntroDB found data for TMDB ${tmdbId}:`, { + intervals: intervals.length, + hasCredits: !!credits + }); + } + + return { intervals, credits }; + } catch (error: any) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + logger.log(`[IntroService] No TheIntroDB data for TMDB ${tmdbId}`); + return { intervals: [], credits: null }; + } + + logger.error('[IntroService] Error fetching from TheIntroDB:', error?.message || error); + return { intervals: [], credits: null }; + } +} + async function fetchFromIntroDb(imdbId: string, season: number, episode: number): Promise { try { const response = await axios.get(`${INTRODB_API_URL}/intro`, { @@ -195,19 +287,52 @@ export async function getSkipTimes( season: number, episode: number, malId?: string, - kitsuId?: string -): Promise { - // 1. Try IntroDB (TV Shows) first - if (imdbId) { - const introDbIntervals = await fetchFromIntroDb(imdbId, season, episode); - if (introDbIntervals.length > 0) { - return introDbIntervals; + kitsuId?: string, + tmdbId?: number, + type?: 'movie' | 'tv' +): Promise<{ intervals: SkipInterval[], credits: CreditsInfo | null }> { + // Get user preference for intro source + const introDbSource = mmkvStorage.getString('introDbSource') || 'theintrodb'; + + if (introDbSource === 'theintrodb') { + // User prefers TheIntroDB (new API) + // 1. Try TheIntroDB (Primary) - Supports both movies and TV shows + if (tmdbId && type) { + const theIntroDbResult = await fetchFromTheIntroDb(tmdbId, type, season, episode); + if (theIntroDbResult.intervals.length > 0 || theIntroDbResult.credits) { + return theIntroDbResult; + } + } + + // 2. Try old IntroDB (Fallback for TV Shows) + if (imdbId) { + const introDbIntervals = await fetchFromIntroDb(imdbId, season, episode); + if (introDbIntervals.length > 0) { + return { intervals: introDbIntervals, credits: null }; + } + } + } else { + // User prefers IntroDB (legacy) + // 1. Try old IntroDB first + if (imdbId) { + const introDbIntervals = await fetchFromIntroDb(imdbId, season, episode); + if (introDbIntervals.length > 0) { + return { intervals: introDbIntervals, credits: null }; + } + } + + // 2. Try TheIntroDB as fallback + if (tmdbId && type) { + const theIntroDbResult = await fetchFromTheIntroDb(tmdbId, type, season, episode); + if (theIntroDbResult.intervals.length > 0 || theIntroDbResult.credits) { + return theIntroDbResult; + } } } - // 2. Try AniSkip (Anime) if we have MAL ID or Kitsu ID + // 3. Try AniSkip (Anime) if we have MAL ID or Kitsu ID let finalMalId = malId; - + // 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}`); @@ -232,11 +357,11 @@ export async function getSkipTimes( const aniSkipIntervals = await fetchFromAniSkip(finalMalId, episode); if (aniSkipIntervals.length > 0) { logger.log(`[IntroService] Found ${aniSkipIntervals.length} skip intervals from AniSkip`); - return aniSkipIntervals; + return { intervals: aniSkipIntervals, credits: null }; } } - return []; + return { intervals: [], credits: null }; } /**