Merge pull request #358 from paregi12/feature/ani-skip

feat: implement AniSkip support in video player
This commit is contained in:
Nayif 2026-01-07 22:12:54 +05:30 committed by GitHub
commit 5afd3d6b08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 329 additions and 69 deletions

2
package-lock.json generated
View file

@ -105,7 +105,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"
} }
}, },

View file

@ -105,7 +105,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

View file

@ -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}

View file

@ -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,

View file

@ -1,12 +1,26 @@
import axios from 'axios'; import axios from 'axios';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { tmdbService } from './tmdbService';
/** /**
* IntroDB API service for fetching TV show intro timestamps * IntroDB API service for fetching TV show intro timestamps
* 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';
const ARM_IMDB_URL = 'https://arm.haglund.dev/api/v2/imdb';
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 +33,128 @@ export interface IntroTimestamps {
confidence: number; confidence: number;
} }
/** async function getMalIdFromArm(imdbId: 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(ARM_IMDB_URL, {
params: {
id: imdbId,
include: 'myanimelist'
}
});
// ARM returns an array of matches (e.g. for different seasons)
// We typically take the first one or try to match logic if possible
if (Array.isArray(response.data) && response.data.length > 0) {
const result = response.data[0];
if (result && result.myanimelist) {
logger.log(`[IntroService] Found MAL ID via ARM: ${result.myanimelist}`);
return result.myanimelist.toString();
}
}
} catch (error) {
// Silent fail as this is just one of the resolution methods
// logger.warn('[IntroService] Failed to fetch MAL ID from ARM', error);
}
return null;
}
async function getMalIdFromKitsu(kitsuId: string): Promise<string | null> {
try {
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 getMalIdFromImdb(imdbId: string): Promise<string | null> {
try {
// 1. Try direct Kitsu mapping (IMDb -> Kitsu)
const kitsuDirectResponse = await axios.get(`${KITSU_API_URL}/mappings`, {
params: {
'filter[external_site]': 'imdb',
'filter[external_id]': imdbId,
'include': 'item'
}
});
if (kitsuDirectResponse.data?.data?.length > 0) {
const kitsuId = kitsuDirectResponse.data.data[0].relationships?.item?.data?.id;
if (kitsuId) {
return await getMalIdFromKitsu(kitsuId);
}
}
// 2. Try TMDB -> TVDB -> Kitsu path (Robust for Cinemeta users)
const tmdbId = await tmdbService.findTMDBIdByIMDB(imdbId);
if (tmdbId) {
const extIds = await tmdbService.getShowExternalIds(tmdbId);
const tvdbId = extIds?.tvdb_id;
if (tvdbId) {
// Search Kitsu for TVDB mapping
const kitsuTvdbResponse = await axios.get(`${KITSU_API_URL}/mappings`, {
params: {
'filter[external_site]': 'thetvdb/series',
'filter[external_id]': tvdbId.toString(),
'include': 'item'
}
});
if (kitsuTvdbResponse.data?.data?.length > 0) {
const kitsuId = kitsuTvdbResponse.data.data[0].relationships?.item?.data?.id;
if (kitsuId) {
logger.log(`[IntroService] Resolved Kitsu ID ${kitsuId} from TVDB ID ${tvdbId} (via IMDb ${imdbId})`);
return await getMalIdFromKitsu(kitsuId);
}
}
}
}
} catch (error) {
// Silent fail - it might just not be an anime or API limit reached
}
return null;
}
async function fetchFromAniSkip(malId: string, episode: number): Promise<SkipInterval[]> {
try {
// Fetch OP, ED, and Recap
// AniSkip expects repeated 'types' parameters without brackets: ?types=op&types=ed...
// episodeLength=0 is required for validation
const types = ['op', 'ed', 'recap', 'mixed-op', 'mixed-ed'];
const queryParams = types.map(t => `types=${t}`).join('&');
const url = `${ANISKIP_API_URL}/skip-times/${malId}/${episode}?${queryParams}&episodeLength=0`;
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 +169,104 @@ 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 IntroDB (TV Shows) first
if (imdbId) {
const introDbIntervals = await fetchFromIntroDb(imdbId, season, episode);
if (introDbIntervals.length > 0) {
return introDbIntervals;
}
}
// 2. 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 we still don't have MAL ID but have IMDb ID (e.g. Cinemeta), try to resolve it
if (!finalMalId && imdbId) {
// Priority 1: ARM API (Fastest)
logger.log(`[IntroService] Attempting to resolve MAL ID via ARM for: ${imdbId}`);
finalMalId = await getMalIdFromArm(imdbId) || undefined;
// Priority 2: Kitsu/TMDB Chain (Fallback)
if (!finalMalId) {
logger.log(`[IntroService] ARM failed, falling back to Kitsu/TMDB chain for: ${imdbId}`);
finalMalId = await getMalIdFromImdb(imdbId) || 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;
}
}
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;

View file

@ -782,9 +782,9 @@ export class TMDBService {
} }
/** /**
* Get external IDs for a TV show (including IMDb ID) * Get external IDs for a TV show (including IMDb ID and TVDB ID)
*/ */
async getShowExternalIds(tmdbId: number): Promise<{ imdb_id: string | null } | null> { async getShowExternalIds(tmdbId: number): Promise<{ imdb_id: string | null, tvdb_id?: number | null, [key: string]: any } | null> {
const cacheKey = this.generateCacheKey(`tv_${tmdbId}_external_ids`); const cacheKey = this.generateCacheKey(`tv_${tmdbId}_external_ids`);
// Check cache (local or remote) // Check cache (local or remote)