mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-30 12:44:51 +00:00
parental guide overlay init
This commit is contained in:
parent
9375fab06c
commit
eee6f81fca
5 changed files with 486 additions and 8 deletions
|
|
@ -39,6 +39,7 @@ import { EpisodesModal } from './modals/EpisodesModal';
|
||||||
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
||||||
import { ErrorModal } from './modals/ErrorModal';
|
import { ErrorModal } from './modals/ErrorModal';
|
||||||
import { CustomSubtitles } from './subtitles/CustomSubtitles';
|
import { CustomSubtitles } from './subtitles/CustomSubtitles';
|
||||||
|
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
||||||
|
|
||||||
// Android-specific components
|
// Android-specific components
|
||||||
import { VideoSurface } from './android/components/VideoSurface';
|
import { VideoSurface } from './android/components/VideoSurface';
|
||||||
|
|
@ -658,6 +659,15 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
cast={cast}
|
cast={cast}
|
||||||
screenDimensions={playerState.screenDimensions}
|
screenDimensions={playerState.screenDimensions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Parental Guide Overlay - Shows after controls first hide */}
|
||||||
|
<ParentalGuideOverlay
|
||||||
|
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
|
||||||
|
type={type as 'movie' | 'series'}
|
||||||
|
season={season}
|
||||||
|
episode={episode}
|
||||||
|
shouldShow={playerState.isVideoLoaded && !playerState.showControls && !playerState.paused}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<AudioTrackModal
|
<AudioTrackModal
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
||||||
import { ErrorModal } from './modals/ErrorModal';
|
import { ErrorModal } from './modals/ErrorModal';
|
||||||
import CustomSubtitles from './subtitles/CustomSubtitles';
|
import CustomSubtitles from './subtitles/CustomSubtitles';
|
||||||
import ResumeOverlay from './modals/ResumeOverlay';
|
import ResumeOverlay from './modals/ResumeOverlay';
|
||||||
|
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
||||||
import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components';
|
import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components';
|
||||||
|
|
||||||
// Platform-specific components
|
// Platform-specific components
|
||||||
|
|
@ -649,6 +650,15 @@ const KSPlayerCore: React.FC = () => {
|
||||||
screenDimensions={screenDimensions}
|
screenDimensions={screenDimensions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Parental Guide Overlay - Shows after controls first hide */}
|
||||||
|
<ParentalGuideOverlay
|
||||||
|
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
|
||||||
|
type={type as 'movie' | 'series'}
|
||||||
|
season={season}
|
||||||
|
episode={episode}
|
||||||
|
shouldShow={isVideoLoaded && !showControls && !paused}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Up Next Button */}
|
{/* Up Next Button */}
|
||||||
<UpNextButton
|
<UpNextButton
|
||||||
type={type}
|
type={type}
|
||||||
|
|
|
||||||
284
src/components/player/overlays/ParentalGuideOverlay.tsx
Normal file
284
src/components/player/overlays/ParentalGuideOverlay.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withTiming,
|
||||||
|
withDelay,
|
||||||
|
Easing,
|
||||||
|
SharedValue,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { parentalGuideService } from '../../../services/parentalGuideService';
|
||||||
|
import { logger } from '../../../utils/logger';
|
||||||
|
|
||||||
|
interface ParentalGuideOverlayProps {
|
||||||
|
imdbId: string | undefined;
|
||||||
|
type: 'movie' | 'series';
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
shouldShow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WarningItem {
|
||||||
|
label: string;
|
||||||
|
severity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLabel = (key: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
nudity: 'Nudity',
|
||||||
|
violence: 'Violence',
|
||||||
|
profanity: 'Profanity',
|
||||||
|
alcohol: 'Alcohol/Drugs',
|
||||||
|
frightening: 'Frightening',
|
||||||
|
};
|
||||||
|
return labels[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Row height for calculating line animation
|
||||||
|
const ROW_HEIGHT = 18;
|
||||||
|
|
||||||
|
// Separate component for each warning item
|
||||||
|
const WarningItemView: React.FC<{
|
||||||
|
item: WarningItem;
|
||||||
|
opacity: SharedValue<number>;
|
||||||
|
}> = ({ item, opacity }) => {
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.warningItem, animatedStyle]}>
|
||||||
|
<Text style={styles.label}>{item.label}</Text>
|
||||||
|
<Text style={styles.separator}>·</Text>
|
||||||
|
<Text style={styles.severity}>{item.severity}</Text>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ParentalGuideOverlay: React.FC<ParentalGuideOverlayProps> = ({
|
||||||
|
imdbId,
|
||||||
|
type,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
shouldShow,
|
||||||
|
}) => {
|
||||||
|
const [warnings, setWarnings] = useState<WarningItem[]>([]);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const hasShownRef = useRef(false);
|
||||||
|
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const fadeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Animation values
|
||||||
|
const lineHeight = useSharedValue(0);
|
||||||
|
const containerOpacity = useSharedValue(0);
|
||||||
|
const itemOpacity0 = useSharedValue(0);
|
||||||
|
const itemOpacity1 = useSharedValue(0);
|
||||||
|
const itemOpacity2 = useSharedValue(0);
|
||||||
|
const itemOpacity3 = useSharedValue(0);
|
||||||
|
const itemOpacity4 = useSharedValue(0);
|
||||||
|
|
||||||
|
const itemOpacities = [itemOpacity0, itemOpacity1, itemOpacity2, itemOpacity3, itemOpacity4];
|
||||||
|
|
||||||
|
// Fetch parental guide data
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (!imdbId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
if (type === 'movie') {
|
||||||
|
data = await parentalGuideService.getMovieGuide(imdbId);
|
||||||
|
} else if (type === 'series' && season && episode) {
|
||||||
|
data = await parentalGuideService.getTVGuide(imdbId, season, episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.parentalGuide) {
|
||||||
|
const guide = data.parentalGuide;
|
||||||
|
const items: WarningItem[] = [];
|
||||||
|
|
||||||
|
Object.entries(guide).forEach(([key, severity]) => {
|
||||||
|
if (severity && severity.toLowerCase() !== 'none') {
|
||||||
|
items.push({
|
||||||
|
label: formatLabel(key),
|
||||||
|
severity: severity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const severityOrder = { severe: 0, moderate: 1, mild: 2, none: 3 };
|
||||||
|
items.sort((a, b) => {
|
||||||
|
const orderA = severityOrder[a.severity.toLowerCase() as keyof typeof severityOrder] ?? 3;
|
||||||
|
const orderB = severityOrder[b.severity.toLowerCase() as keyof typeof severityOrder] ?? 3;
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
|
||||||
|
setWarnings(items.slice(0, 5));
|
||||||
|
logger.log('[ParentalGuideOverlay] Loaded warnings:', items.length);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[ParentalGuideOverlay] Error fetching guide:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [imdbId, type, season, episode]);
|
||||||
|
|
||||||
|
// Trigger animation when shouldShow becomes true
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldShow && warnings.length > 0 && !hasShownRef.current) {
|
||||||
|
hasShownRef.current = true;
|
||||||
|
setIsVisible(true);
|
||||||
|
|
||||||
|
const count = warnings.length;
|
||||||
|
// Line height = (row height * count) + (gap * (count - 1))
|
||||||
|
const gap = 2; // matches styles.itemsContainer gap
|
||||||
|
const totalLineHeight = (count * ROW_HEIGHT) + ((count - 1) * gap);
|
||||||
|
|
||||||
|
// Container fade in
|
||||||
|
containerOpacity.value = withTiming(1, { duration: 300 });
|
||||||
|
|
||||||
|
// FADE IN: Line grows from top to bottom first
|
||||||
|
lineHeight.value = withTiming(totalLineHeight, {
|
||||||
|
duration: 400,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then each item fades in one by one (after line animation)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
itemOpacities[i].value = withDelay(
|
||||||
|
400 + i * 80, // Start after line, stagger each
|
||||||
|
withTiming(1, { duration: 200 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-hide after 5 seconds
|
||||||
|
hideTimeoutRef.current = setTimeout(() => {
|
||||||
|
// FADE OUT: Items fade out in reverse order (bottom to top)
|
||||||
|
for (let i = count - 1; i >= 0; i--) {
|
||||||
|
const reverseDelay = (count - 1 - i) * 60;
|
||||||
|
itemOpacities[i].value = withDelay(
|
||||||
|
reverseDelay,
|
||||||
|
withTiming(0, { duration: 150 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line shrinks after items are gone
|
||||||
|
const lineDelay = count * 60 + 100;
|
||||||
|
lineHeight.value = withDelay(lineDelay, withTiming(0, {
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.in(Easing.cubic),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Container fades out last
|
||||||
|
containerOpacity.value = withDelay(lineDelay + 200, withTiming(0, { duration: 200 }));
|
||||||
|
|
||||||
|
// Set invisible after all animations complete
|
||||||
|
fadeTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
}, lineDelay + 500);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}, [shouldShow, warnings.length]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
||||||
|
if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reset when content changes
|
||||||
|
useEffect(() => {
|
||||||
|
hasShownRef.current = false;
|
||||||
|
setWarnings([]);
|
||||||
|
setIsVisible(false);
|
||||||
|
lineHeight.value = 0;
|
||||||
|
containerOpacity.value = 0;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
itemOpacities[i].value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideTimeoutRef.current) {
|
||||||
|
clearTimeout(hideTimeoutRef.current);
|
||||||
|
hideTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
if (fadeTimeoutRef.current) {
|
||||||
|
clearTimeout(fadeTimeoutRef.current);
|
||||||
|
fadeTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, [imdbId, season, episode]);
|
||||||
|
|
||||||
|
const containerStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: containerOpacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lineStyle = useAnimatedStyle(() => ({
|
||||||
|
height: lineHeight.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!isVisible || warnings.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.container, containerStyle]} pointerEvents="none">
|
||||||
|
{/* Vertical line - animates height */}
|
||||||
|
<Animated.View style={[styles.line, lineStyle]} />
|
||||||
|
|
||||||
|
{/* Warning items */}
|
||||||
|
<View style={styles.itemsContainer}>
|
||||||
|
{warnings.map((item, index) => (
|
||||||
|
<WarningItemView
|
||||||
|
key={item.label}
|
||||||
|
item={item}
|
||||||
|
opacity={itemOpacities[index]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 50,
|
||||||
|
left: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
line: {
|
||||||
|
width: 2,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
borderRadius: 1,
|
||||||
|
marginRight: 10,
|
||||||
|
},
|
||||||
|
itemsContainer: {
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
warningItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: ROW_HEIGHT,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.85)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.4)',
|
||||||
|
fontSize: 11,
|
||||||
|
marginHorizontal: 5,
|
||||||
|
},
|
||||||
|
severity: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ParentalGuideOverlay;
|
||||||
120
src/services/parentalGuideService.ts
Normal file
120
src/services/parentalGuideService.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
// Base URL for the parental guide API - configurable via env
|
||||||
|
const API_BASE_URL = process.env.EXPO_PUBLIC_PARENTAL_GUIDE_API_URL || 'https://parental.nuvioapp.space';
|
||||||
|
const TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
export interface ParentalGuide {
|
||||||
|
nudity: 'None' | 'Mild' | 'Moderate' | 'Severe';
|
||||||
|
violence: 'None' | 'Mild' | 'Moderate' | 'Severe';
|
||||||
|
profanity: 'None' | 'Mild' | 'Moderate' | 'Severe';
|
||||||
|
alcohol: 'None' | 'Mild' | 'Moderate' | 'Severe';
|
||||||
|
frightening: 'None' | 'Mild' | 'Moderate' | 'Severe';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParentalGuideResponse {
|
||||||
|
imdbId: string;
|
||||||
|
parentalGuide: ParentalGuide;
|
||||||
|
hasData: boolean;
|
||||||
|
seriesId?: string;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
cached?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ParentalGuideService {
|
||||||
|
private static instance: ParentalGuideService;
|
||||||
|
private cache: Map<string, ParentalGuideResponse> = new Map();
|
||||||
|
|
||||||
|
private constructor() { }
|
||||||
|
|
||||||
|
public static getInstance(): ParentalGuideService {
|
||||||
|
if (!ParentalGuideService.instance) {
|
||||||
|
ParentalGuideService.instance = new ParentalGuideService();
|
||||||
|
}
|
||||||
|
return ParentalGuideService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get parental guide for a movie
|
||||||
|
*/
|
||||||
|
async getMovieGuide(imdbId: string): Promise<ParentalGuideResponse | null> {
|
||||||
|
if (!imdbId || !imdbId.startsWith('tt')) {
|
||||||
|
logger.log('[ParentalGuide] Invalid IMDb ID:', imdbId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `movie:${imdbId}`;
|
||||||
|
if (this.cache.has(cacheKey)) {
|
||||||
|
return this.cache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${API_BASE_URL}/movie/${imdbId}`;
|
||||||
|
logger.log('[ParentalGuide] Fetching movie guide:', url);
|
||||||
|
|
||||||
|
const response = await axios.get<ParentalGuideResponse>(url, {
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.hasData) {
|
||||||
|
this.cache.set(cacheKey, response.data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[ParentalGuide] Failed to fetch movie guide:', error?.message || error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get parental guide for a TV episode
|
||||||
|
*/
|
||||||
|
async getTVGuide(imdbId: string, season: number, episode: number): Promise<ParentalGuideResponse | null> {
|
||||||
|
if (!imdbId || !imdbId.startsWith('tt')) {
|
||||||
|
logger.log('[ParentalGuide] Invalid IMDb ID:', imdbId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!season || !episode || season < 0 || episode < 0) {
|
||||||
|
logger.log('[ParentalGuide] Invalid season/episode:', { season, episode });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `tv:${imdbId}:${season}:${episode}`;
|
||||||
|
if (this.cache.has(cacheKey)) {
|
||||||
|
return this.cache.get(cacheKey)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${API_BASE_URL}/tv/${imdbId}/${season}/${episode}`;
|
||||||
|
logger.log('[ParentalGuide] Fetching TV guide:', url);
|
||||||
|
|
||||||
|
const response = await axios.get<ParentalGuideResponse>(url, {
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.hasData) {
|
||||||
|
this.cache.set(cacheKey, response.data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('[ParentalGuide] Failed to fetch TV guide:', error?.message || error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cache
|
||||||
|
*/
|
||||||
|
clearCache(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parentalGuideService = ParentalGuideService.getInstance();
|
||||||
|
|
@ -1691,34 +1691,85 @@ class StremioService {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
// Collect from all installed addons that expose a subtitles resource
|
// Collect from all installed addons that expose a subtitles resource
|
||||||
const addons = this.getInstalledAddons();
|
const addons = this.getInstalledAddons();
|
||||||
|
|
||||||
|
// The ID to check for prefix matching - use videoId for series (e.g., tt1234567:1:1), otherwise use id
|
||||||
|
const idForChecking = type === 'series' && videoId
|
||||||
|
? videoId.replace('series:', '')
|
||||||
|
: id;
|
||||||
|
|
||||||
const subtitleAddons = addons.filter(addon => {
|
const subtitleAddons = addons.filter(addon => {
|
||||||
if (!addon.resources) return false;
|
if (!addon.resources) return false;
|
||||||
return addon.resources.some((resource: any) => {
|
|
||||||
|
// Check if addon has subtitles resource
|
||||||
|
const subtitlesResource = addon.resources.find((resource: any) => {
|
||||||
if (typeof resource === 'string') return resource === 'subtitles';
|
if (typeof resource === 'string') return resource === 'subtitles';
|
||||||
return resource && resource.name === 'subtitles';
|
return resource && resource.name === 'subtitles';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!subtitlesResource) return false;
|
||||||
|
|
||||||
|
// Check type support - either from the resource object or addon-level types
|
||||||
|
let supportsType = true;
|
||||||
|
if (typeof subtitlesResource === 'object' && subtitlesResource.types) {
|
||||||
|
supportsType = subtitlesResource.types.includes(type);
|
||||||
|
} else if (addon.types) {
|
||||||
|
supportsType = addon.types.includes(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportsType) {
|
||||||
|
logger.log(`[getSubtitles] Addon ${addon.name} does not support type ${type}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check idPrefixes - either from the resource object or addon-level
|
||||||
|
let supportsIdPrefix = true;
|
||||||
|
let idPrefixes: string[] | undefined;
|
||||||
|
|
||||||
|
if (typeof subtitlesResource === 'object' && subtitlesResource.idPrefixes) {
|
||||||
|
idPrefixes = subtitlesResource.idPrefixes;
|
||||||
|
} else if (addon.idPrefixes) {
|
||||||
|
idPrefixes = addon.idPrefixes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idPrefixes && idPrefixes.length > 0) {
|
||||||
|
supportsIdPrefix = idPrefixes.some(prefix => idForChecking.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supportsIdPrefix) {
|
||||||
|
logger.log(`[getSubtitles] Addon ${addon.name} does not support ID prefix for ${idForChecking} (requires: ${idPrefixes?.join(', ')})`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (subtitleAddons.length === 0) {
|
if (subtitleAddons.length === 0) {
|
||||||
logger.warn('No subtitle-capable addons installed');
|
logger.warn('No subtitle-capable addons installed that support the requested type/id');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.log(`[getSubtitles] Found ${subtitleAddons.length} subtitle addons for ${type}/${id}: ${subtitleAddons.map(a => a.name).join(', ')}`);
|
||||||
|
|
||||||
const requests = subtitleAddons.map(async (addon) => {
|
const requests = subtitleAddons.map(async (addon) => {
|
||||||
if (!addon.url) return [] as Subtitle[];
|
if (!addon.url) return [] as Subtitle[];
|
||||||
try {
|
try {
|
||||||
const { baseUrl } = this.getAddonBaseURL(addon.url || '');
|
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
|
||||||
let url = '';
|
let url = '';
|
||||||
if (type === 'series' && videoId) {
|
if (type === 'series' && videoId) {
|
||||||
const episodeInfo = encodeURIComponent(videoId.replace('series:', ''));
|
const episodeInfo = encodeURIComponent(videoId.replace('series:', ''));
|
||||||
url = `${baseUrl}/subtitles/series/${episodeInfo}.json`;
|
url = queryParams
|
||||||
|
? `${baseUrl}/subtitles/series/${episodeInfo}.json?${queryParams}`
|
||||||
|
: `${baseUrl}/subtitles/series/${episodeInfo}.json`;
|
||||||
} else {
|
} else {
|
||||||
const encodedId = encodeURIComponent(id);
|
const encodedId = encodeURIComponent(id);
|
||||||
url = `${baseUrl}/subtitles/${type}/${encodedId}.json`;
|
url = queryParams
|
||||||
|
? `${baseUrl}/subtitles/${type}/${encodedId}.json?${queryParams}`
|
||||||
|
: `${baseUrl}/subtitles/${type}/${encodedId}.json`;
|
||||||
}
|
}
|
||||||
logger.log(`Fetching subtitles from ${addon.name}: ${url}`);
|
logger.log(`[getSubtitles] Fetching subtitles from ${addon.name}: ${url}`);
|
||||||
const response = await this.retryRequest(async () => axios.get(url, { timeout: 10000 }));
|
const response = await this.retryRequest(async () => axios.get(url, { timeout: 10000 }));
|
||||||
if (response.data && Array.isArray(response.data.subtitles)) {
|
if (response.data && Array.isArray(response.data.subtitles)) {
|
||||||
|
logger.log(`[getSubtitles] Got ${response.data.subtitles.length} subtitles from ${addon.name}`);
|
||||||
return response.data.subtitles.map((sub: any, index: number) => ({
|
return response.data.subtitles.map((sub: any, index: number) => ({
|
||||||
// Ensure ID is always present per protocol (required field)
|
// Ensure ID is always present per protocol (required field)
|
||||||
id: sub.id || `${addon.id}-${sub.lang || 'unknown'}-${index}`,
|
id: sub.id || `${addon.id}-${sub.lang || 'unknown'}-${index}`,
|
||||||
|
|
@ -1726,9 +1777,11 @@ class StremioService {
|
||||||
addon: addon.id,
|
addon: addon.id,
|
||||||
addonName: addon.name,
|
addonName: addon.name,
|
||||||
})) as Subtitle[];
|
})) as Subtitle[];
|
||||||
|
} else {
|
||||||
|
logger.log(`[getSubtitles] No subtitles array in response from ${addon.name}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
logger.error(`Failed to fetch subtitles from ${addon.name}:`, error);
|
logger.error(`[getSubtitles] Failed to fetch subtitles from ${addon.name}:`, error?.message || error);
|
||||||
}
|
}
|
||||||
return [] as Subtitle[];
|
return [] as Subtitle[];
|
||||||
});
|
});
|
||||||
|
|
@ -1744,6 +1797,7 @@ class StremioService {
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
logger.log(`[getSubtitles] Total: ${deduped.length} unique subtitles from all addons`);
|
||||||
return deduped;
|
return deduped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue