mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-22 09:11:56 +00:00
feat: implement AniSkip support in video player
This commit is contained in:
parent
3d5a9ebf42
commit
3de2fb4809
5 changed files with 232 additions and 67 deletions
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -100,7 +100,7 @@
|
||||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"react-native-svg-transformer": "^1.5.0",
|
"react-native-svg-transformer": "^1.5.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.9.3",
|
||||||
"xcode": "^3.0.1"
|
"xcode": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@
|
||||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"react-native-svg-transformer": "^1.5.0",
|
"react-native-svg-transformer": "^1.5.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.9.3",
|
||||||
"xcode": "^3.0.1"
|
"xcode": "^3.0.1"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
|
|
|
||||||
|
|
@ -943,6 +943,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
type={type || 'movie'}
|
type={type || 'movie'}
|
||||||
season={season}
|
season={season}
|
||||||
episode={episode}
|
episode={episode}
|
||||||
|
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
|
||||||
|
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
|
||||||
currentTime={playerState.currentTime}
|
currentTime={playerState.currentTime}
|
||||||
onSkip={(endTime) => controlsHook.seekToTime(endTime)}
|
onSkip={(endTime) => controlsHook.seekToTime(endTime)}
|
||||||
controlsVisible={playerState.showControls}
|
controlsVisible={playerState.showControls}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import Animated, {
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { introService, IntroTimestamps } from '../../../services/introService';
|
import { introService, SkipInterval, SkipType } from '../../../services/introService';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
|
|
||||||
|
|
@ -19,6 +19,8 @@ interface SkipIntroButtonProps {
|
||||||
type: 'movie' | 'series' | string;
|
type: 'movie' | 'series' | string;
|
||||||
season?: number;
|
season?: number;
|
||||||
episode?: number;
|
episode?: number;
|
||||||
|
malId?: string;
|
||||||
|
kitsuId?: string;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
onSkip: (endTime: number) => void;
|
onSkip: (endTime: number) => void;
|
||||||
controlsVisible?: boolean;
|
controlsVisible?: boolean;
|
||||||
|
|
@ -30,6 +32,8 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
type,
|
type,
|
||||||
season,
|
season,
|
||||||
episode,
|
episode,
|
||||||
|
malId,
|
||||||
|
kitsuId,
|
||||||
currentTime,
|
currentTime,
|
||||||
onSkip,
|
onSkip,
|
||||||
controlsVisible = false,
|
controlsVisible = false,
|
||||||
|
|
@ -37,10 +41,15 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [introData, setIntroData] = useState<IntroTimestamps | null>(null);
|
|
||||||
|
// State
|
||||||
|
const [skipIntervals, setSkipIntervals] = useState<SkipInterval[]>([]);
|
||||||
|
const [currentInterval, setCurrentInterval] = useState<SkipInterval | null>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [hasSkipped, setHasSkipped] = useState(false);
|
const [hasSkippedCurrent, setHasSkippedCurrent] = useState(false);
|
||||||
const [autoHidden, setAutoHidden] = useState(false);
|
const [autoHidden, setAutoHidden] = useState(false);
|
||||||
|
|
||||||
|
// Refs
|
||||||
const fetchedRef = useRef(false);
|
const fetchedRef = useRef(false);
|
||||||
const lastEpisodeRef = useRef<string>('');
|
const lastEpisodeRef = useRef<string>('');
|
||||||
const autoHideTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const autoHideTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
@ -50,14 +59,13 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
const scale = useSharedValue(0.8);
|
const scale = useSharedValue(0.8);
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
|
|
||||||
// Fetch intro data when episode changes
|
// Fetch skip data when episode changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const episodeKey = `${imdbId}-${season}-${episode}`;
|
const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`;
|
||||||
|
|
||||||
// Skip if not a series or missing required data
|
// 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 || !season || !episode) {
|
if (type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) {
|
||||||
logger.log(`[SkipIntroButton] Skipping fetch - type: ${type}, imdbId: ${imdbId}, season: ${season}, episode: ${episode}`);
|
setSkipIntervals([]);
|
||||||
setIntroData(null);
|
|
||||||
fetchedRef.current = false;
|
fetchedRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -69,45 +77,76 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
|
|
||||||
lastEpisodeRef.current = episodeKey;
|
lastEpisodeRef.current = episodeKey;
|
||||||
fetchedRef.current = true;
|
fetchedRef.current = true;
|
||||||
setHasSkipped(false);
|
setHasSkippedCurrent(false);
|
||||||
setAutoHidden(false);
|
setAutoHidden(false);
|
||||||
|
setSkipIntervals([]);
|
||||||
|
|
||||||
const fetchIntroData = async () => {
|
const fetchSkipData = async () => {
|
||||||
logger.log(`[SkipIntroButton] Fetching intro data for ${imdbId} S${season}E${episode}...`);
|
logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`);
|
||||||
try {
|
try {
|
||||||
const data = await introService.getIntroTimestamps(imdbId, season, episode);
|
const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId);
|
||||||
setIntroData(data);
|
setSkipIntervals(intervals);
|
||||||
|
|
||||||
if (data) {
|
if (intervals.length > 0) {
|
||||||
logger.log(`[SkipIntroButton] ✓ Found intro: ${data.start_sec}s - ${data.end_sec}s (confidence: ${data.confidence})`);
|
logger.log(`[SkipIntroButton] ✓ Found ${intervals.length} skip intervals:`, intervals);
|
||||||
} else {
|
} else {
|
||||||
logger.log(`[SkipIntroButton] ✗ No intro data available for this episode`);
|
logger.log(`[SkipIntroButton] ✗ No skip data available for this episode`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[SkipIntroButton] Error fetching intro data:', error);
|
logger.error('[SkipIntroButton] Error fetching skip data:', error);
|
||||||
setIntroData(null);
|
setSkipIntervals([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchIntroData();
|
fetchSkipData();
|
||||||
}, [imdbId, type, season, episode]);
|
}, [imdbId, type, season, episode, malId, kitsuId]);
|
||||||
|
|
||||||
// Determine if button should show based on current playback position
|
// Determine active interval based on current playback position
|
||||||
|
useEffect(() => {
|
||||||
|
if (skipIntervals.length === 0) {
|
||||||
|
setCurrentInterval(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find an interval that contains the current time
|
||||||
|
const active = skipIntervals.find(
|
||||||
|
interval => currentTime >= interval.startTime && currentTime < (interval.endTime - 0.5)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
// If we found a new active interval that is different from the previous one
|
||||||
|
if (!currentInterval ||
|
||||||
|
active.startTime !== currentInterval.startTime ||
|
||||||
|
active.type !== currentInterval.type) {
|
||||||
|
logger.log(`[SkipIntroButton] Entering interval: ${active.type} (${active.startTime}-${active.endTime})`);
|
||||||
|
setCurrentInterval(active);
|
||||||
|
setHasSkippedCurrent(false); // Reset skipped state for new interval
|
||||||
|
setAutoHidden(false); // Reset auto-hide for new interval
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No active interval
|
||||||
|
if (currentInterval) {
|
||||||
|
logger.log('[SkipIntroButton] Exiting interval');
|
||||||
|
setCurrentInterval(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentTime, skipIntervals]);
|
||||||
|
|
||||||
|
// Determine if button should show
|
||||||
const shouldShowButton = useCallback(() => {
|
const shouldShowButton = useCallback(() => {
|
||||||
if (!introData || hasSkipped) return false;
|
if (!currentInterval || hasSkippedCurrent) return false;
|
||||||
// Show when within intro range, with a small buffer at the end
|
|
||||||
const inIntroRange = currentTime >= introData.start_sec && currentTime < (introData.end_sec - 0.5);
|
|
||||||
// If auto-hidden, only show when controls are visible
|
// If auto-hidden, only show when controls are visible
|
||||||
if (autoHidden && !controlsVisible) return false;
|
if (autoHidden && !controlsVisible) return false;
|
||||||
return inIntroRange;
|
|
||||||
}, [introData, currentTime, hasSkipped, autoHidden, controlsVisible]);
|
return true;
|
||||||
|
}, [currentInterval, hasSkippedCurrent, autoHidden, controlsVisible]);
|
||||||
|
|
||||||
// Handle visibility animations
|
// Handle visibility animations
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const shouldShow = shouldShowButton();
|
const shouldShow = shouldShowButton();
|
||||||
|
|
||||||
if (shouldShow && !isVisible) {
|
if (shouldShow && !isVisible) {
|
||||||
logger.log(`[SkipIntroButton] Showing button - currentTime: ${currentTime.toFixed(1)}s, intro: ${introData?.start_sec}s - ${introData?.end_sec}s`);
|
|
||||||
setIsVisible(true);
|
setIsVisible(true);
|
||||||
opacity.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.cubic) });
|
opacity.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.cubic) });
|
||||||
scale.value = withSpring(1, { damping: 15, stiffness: 150 });
|
scale.value = withSpring(1, { damping: 15, stiffness: 150 });
|
||||||
|
|
@ -115,8 +154,7 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
// Start 15-second auto-hide timer
|
// Start 15-second auto-hide timer
|
||||||
if (autoHideTimerRef.current) clearTimeout(autoHideTimerRef.current);
|
if (autoHideTimerRef.current) clearTimeout(autoHideTimerRef.current);
|
||||||
autoHideTimerRef.current = setTimeout(() => {
|
autoHideTimerRef.current = setTimeout(() => {
|
||||||
if (!hasSkipped) {
|
if (!hasSkippedCurrent) {
|
||||||
logger.log('[SkipIntroButton] Auto-hiding after 15 seconds');
|
|
||||||
setAutoHidden(true);
|
setAutoHidden(true);
|
||||||
opacity.value = withTiming(0, { duration: 200 });
|
opacity.value = withTiming(0, { duration: 200 });
|
||||||
scale.value = withTiming(0.8, { duration: 200 });
|
scale.value = withTiming(0.8, { duration: 200 });
|
||||||
|
|
@ -124,25 +162,20 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
}
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
} else if (!shouldShow && isVisible) {
|
} else if (!shouldShow && isVisible) {
|
||||||
logger.log(`[SkipIntroButton] Hiding button - currentTime: ${currentTime.toFixed(1)}s, hasSkipped: ${hasSkipped}`);
|
|
||||||
if (autoHideTimerRef.current) clearTimeout(autoHideTimerRef.current);
|
if (autoHideTimerRef.current) clearTimeout(autoHideTimerRef.current);
|
||||||
opacity.value = withTiming(0, { duration: 200 });
|
opacity.value = withTiming(0, { duration: 200 });
|
||||||
scale.value = withTiming(0.8, { duration: 200 });
|
scale.value = withTiming(0.8, { duration: 200 });
|
||||||
// Delay hiding to allow animation to complete
|
// Delay hiding to allow animation to complete
|
||||||
setTimeout(() => setIsVisible(false), 250);
|
setTimeout(() => setIsVisible(false), 250);
|
||||||
}
|
}
|
||||||
}, [shouldShowButton, isVisible]);
|
}, [shouldShowButton, isVisible, hasSkippedCurrent]);
|
||||||
|
|
||||||
// Re-show when controls become visible (if still in intro range and was auto-hidden)
|
// Re-show when controls become visible (if still in interval and was auto-hidden)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (controlsVisible && autoHidden && introData && !hasSkipped) {
|
if (controlsVisible && autoHidden && currentInterval && !hasSkippedCurrent) {
|
||||||
const inIntroRange = currentTime >= introData.start_sec && currentTime < (introData.end_sec - 0.5);
|
setAutoHidden(false);
|
||||||
if (inIntroRange) {
|
|
||||||
logger.log('[SkipIntroButton] Re-showing button because controls became visible');
|
|
||||||
setAutoHidden(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [controlsVisible, autoHidden, introData, hasSkipped, currentTime]);
|
}, [controlsVisible, autoHidden, currentInterval, hasSkippedCurrent]);
|
||||||
|
|
||||||
// Cleanup timer on unmount
|
// Cleanup timer on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -162,12 +195,32 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
|
|
||||||
// Handle skip action
|
// Handle skip action
|
||||||
const handleSkip = useCallback(() => {
|
const handleSkip = useCallback(() => {
|
||||||
if (!introData) return;
|
if (!currentInterval) return;
|
||||||
|
|
||||||
logger.log(`[SkipIntroButton] User pressed Skip Intro - seeking to ${introData.end_sec}s (from ${currentTime.toFixed(1)}s)`);
|
logger.log(`[SkipIntroButton] User pressed Skip - seeking to ${currentInterval.endTime}s`);
|
||||||
setHasSkipped(true);
|
setHasSkippedCurrent(true);
|
||||||
onSkip(introData.end_sec);
|
onSkip(currentInterval.endTime);
|
||||||
}, [introData, onSkip, currentTime]);
|
}, [currentInterval, onSkip]);
|
||||||
|
|
||||||
|
// Get display text based on skip type
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (!currentInterval) return 'Skip';
|
||||||
|
|
||||||
|
switch (currentInterval.type) {
|
||||||
|
case 'op':
|
||||||
|
case 'mixed-op':
|
||||||
|
case 'intro':
|
||||||
|
return 'Skip Intro';
|
||||||
|
case 'ed':
|
||||||
|
case 'mixed-ed':
|
||||||
|
case 'outro':
|
||||||
|
return 'Skip Ending';
|
||||||
|
case 'recap':
|
||||||
|
return 'Skip Recap';
|
||||||
|
default:
|
||||||
|
return 'Skip';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Animated styles
|
// Animated styles
|
||||||
const containerStyle = useAnimatedStyle(() => ({
|
const containerStyle = useAnimatedStyle(() => ({
|
||||||
|
|
@ -175,8 +228,8 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
transform: [{ scale: scale.value }, { translateY: translateY.value }],
|
transform: [{ scale: scale.value }, { translateY: translateY.value }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Don't render if not visible or no intro data
|
// Don't render if not visible (and animation complete)
|
||||||
if (!isVisible || !introData) {
|
if (!isVisible && opacity.value === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,7 +261,7 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
color="#FFFFFF"
|
color="#FFFFFF"
|
||||||
style={styles.icon}
|
style={styles.icon}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.text}>Skip Intro</Text>
|
<Text style={styles.text}>{getButtonText()}</Text>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.accentBar,
|
styles.accentBar,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,19 @@ import { logger } from '../utils/logger';
|
||||||
* API Documentation: https://api.introdb.app
|
* API Documentation: https://api.introdb.app
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_BASE_URL = process.env.EXPO_PUBLIC_INTRODB_API_URL;
|
const INTRODB_API_URL = process.env.EXPO_PUBLIC_INTRODB_API_URL;
|
||||||
|
const ANISKIP_API_URL = 'https://api.aniskip.com/v2';
|
||||||
|
const KITSU_API_URL = 'https://kitsu.io/api/edge';
|
||||||
|
|
||||||
|
export type SkipType = 'op' | 'ed' | 'recap' | 'intro' | 'outro' | 'mixed-op' | 'mixed-ed';
|
||||||
|
|
||||||
|
export interface SkipInterval {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
type: SkipType;
|
||||||
|
provider: 'introdb' | 'aniskip';
|
||||||
|
skipId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IntroTimestamps {
|
export interface IntroTimestamps {
|
||||||
imdb_id: string;
|
imdb_id: string;
|
||||||
|
|
@ -19,20 +31,48 @@ export interface IntroTimestamps {
|
||||||
confidence: number;
|
confidence: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function getMalIdFromKitsu(kitsuId: string): Promise<string | null> {
|
||||||
* Fetches intro timestamps for a TV show episode
|
|
||||||
* @param imdbId - IMDB ID of the show (e.g., tt0903747 for Breaking Bad)
|
|
||||||
* @param season - Season number (1-indexed)
|
|
||||||
* @param episode - Episode number (1-indexed)
|
|
||||||
* @returns Intro timestamps or null if not found
|
|
||||||
*/
|
|
||||||
export async function getIntroTimestamps(
|
|
||||||
imdbId: string,
|
|
||||||
season: number,
|
|
||||||
episode: number
|
|
||||||
): Promise<IntroTimestamps | null> {
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get<IntroTimestamps>(`${API_BASE_URL}/intro`, {
|
const response = await axios.get(`${KITSU_API_URL}/anime/${kitsuId}/mappings`);
|
||||||
|
const data = response.data;
|
||||||
|
if (data && data.data) {
|
||||||
|
const malMapping = data.data.find((m: any) => m.attributes.externalSite === 'myanimelist/anime');
|
||||||
|
if (malMapping) {
|
||||||
|
return malMapping.attributes.externalId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('[IntroService] Failed to fetch MAL ID from Kitsu:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFromAniSkip(malId: string, episode: number): Promise<SkipInterval[]> {
|
||||||
|
try {
|
||||||
|
// Fetch OP, ED, and Recap
|
||||||
|
const url = `${ANISKIP_API_URL}/skip-times/${malId}/${episode}?types[]=op&types[]=ed&types[]=recap&types[]=mixed-op&types[]=mixed-ed`;
|
||||||
|
const response = await axios.get(url);
|
||||||
|
|
||||||
|
if (response.data.found && response.data.results) {
|
||||||
|
return response.data.results.map((res: any) => ({
|
||||||
|
startTime: res.interval.startTime,
|
||||||
|
endTime: res.interval.endTime,
|
||||||
|
type: res.skipType,
|
||||||
|
provider: 'aniskip',
|
||||||
|
skipId: res.skipId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status !== 404) {
|
||||||
|
logger.error('[IntroService] Error fetching AniSkip:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFromIntroDb(imdbId: string, season: number, episode: number): Promise<SkipInterval[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<IntroTimestamps>(`${INTRODB_API_URL}/intro`, {
|
||||||
params: {
|
params: {
|
||||||
imdb_id: imdbId,
|
imdb_id: imdbId,
|
||||||
season,
|
season,
|
||||||
|
|
@ -47,21 +87,91 @@ export async function getIntroTimestamps(
|
||||||
confidence: response.data.confidence,
|
confidence: response.data.confidence,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return [{
|
||||||
|
startTime: response.data.start_sec,
|
||||||
|
endTime: response.data.end_sec,
|
||||||
|
type: 'intro',
|
||||||
|
provider: 'introdb'
|
||||||
|
}];
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
// No intro data available for this episode - this is expected
|
// No intro data available for this episode - this is expected
|
||||||
logger.log(`[IntroService] No intro data for ${imdbId} S${season}E${episode}`);
|
logger.log(`[IntroService] No intro data for ${imdbId} S${season}E${episode}`);
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('[IntroService] Error fetching intro timestamps:', error?.message || error);
|
logger.error('[IntroService] Error fetching intro timestamps:', error?.message || error);
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches skip intervals (intro, outro, recap) from available providers
|
||||||
|
*/
|
||||||
|
export async function getSkipTimes(
|
||||||
|
imdbId: string | undefined,
|
||||||
|
season: number,
|
||||||
|
episode: number,
|
||||||
|
malId?: string,
|
||||||
|
kitsuId?: string
|
||||||
|
): Promise<SkipInterval[]> {
|
||||||
|
// 1. 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}`);
|
||||||
|
finalMalId = await getMalIdFromKitsu(kitsuId) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalMalId) {
|
||||||
|
logger.log(`[IntroService] Fetching AniSkip for MAL ID: ${finalMalId} Ep: ${episode}`);
|
||||||
|
const aniSkipIntervals = await fetchFromAniSkip(finalMalId, episode);
|
||||||
|
if (aniSkipIntervals.length > 0) {
|
||||||
|
logger.log(`[IntroService] Found ${aniSkipIntervals.length} skip intervals from AniSkip`);
|
||||||
|
return aniSkipIntervals;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try IntroDB (TV Shows) as fallback or for non-anime
|
||||||
|
if (imdbId) {
|
||||||
|
const introDbIntervals = await fetchFromIntroDb(imdbId, season, episode);
|
||||||
|
if (introDbIntervals.length > 0) {
|
||||||
|
return introDbIntervals;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy function for backward compatibility
|
||||||
|
* Fetches intro timestamps for a TV show episode
|
||||||
|
*/
|
||||||
|
export async function getIntroTimestamps(
|
||||||
|
imdbId: string,
|
||||||
|
season: number,
|
||||||
|
episode: number
|
||||||
|
): Promise<IntroTimestamps | null> {
|
||||||
|
const intervals = await fetchFromIntroDb(imdbId, season, episode);
|
||||||
|
if (intervals.length > 0) {
|
||||||
|
return {
|
||||||
|
imdb_id: imdbId,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
start_sec: intervals[0].startTime,
|
||||||
|
end_sec: intervals[0].endTime,
|
||||||
|
start_ms: intervals[0].startTime * 1000,
|
||||||
|
end_ms: intervals[0].endTime * 1000,
|
||||||
|
confidence: 1.0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const introService = {
|
export const introService = {
|
||||||
getIntroTimestamps,
|
getIntroTimestamps,
|
||||||
|
getSkipTimes
|
||||||
};
|
};
|
||||||
|
|
||||||
export default introService;
|
export default introService;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue