mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
feat: update IntroDB integration to support recap and outro segments
This commit is contained in:
parent
dbb5337204
commit
b857256916
2 changed files with 144 additions and 54 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet, TextInput, ActivityIndicator } from 'react-native';
|
||||
import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet, TextInput, ActivityIndicator, ScrollView } from 'react-native';
|
||||
import { Ionicons, MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Animated, {
|
||||
|
|
@ -9,7 +9,7 @@ import Animated, {
|
|||
SlideOutDown,
|
||||
} from 'react-native-reanimated';
|
||||
import { useSettings } from '../../../hooks/useSettings';
|
||||
import { introService } from '../../../services/introService';
|
||||
import { introService, SkipType } from '../../../services/introService';
|
||||
import { toastService } from '../../../services/toastService';
|
||||
|
||||
interface SubmitIntroModalProps {
|
||||
|
|
@ -67,6 +67,7 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
|
|||
|
||||
const [startTimeStr, setStartTimeStr] = useState('00:00');
|
||||
const [endTimeStr, setEndTimeStr] = useState(formatSecondsToMMSS(currentTime));
|
||||
const [segmentType, setSegmentType] = useState<SkipType>('intro');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -107,14 +108,15 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
|
|||
season,
|
||||
episode,
|
||||
startSec,
|
||||
endSec
|
||||
endSec,
|
||||
segmentType
|
||||
);
|
||||
|
||||
if (success) {
|
||||
toastService.success(t('player_ui.intro_submitted', { defaultValue: 'Intro submitted successfully' }));
|
||||
toastService.success(t('player_ui.intro_submitted', { defaultValue: 'Segment submitted successfully' }));
|
||||
onClose();
|
||||
} else {
|
||||
toastService.error(t('player_ui.intro_submit_failed', { defaultValue: 'Failed to submit intro' }));
|
||||
toastService.error(t('player_ui.intro_submit_failed', { defaultValue: 'Failed to submit segment' }));
|
||||
}
|
||||
} catch (error) {
|
||||
toastService.error('Error', 'An unexpected error occurred');
|
||||
|
|
@ -123,9 +125,11 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const startVal = parseTimeToSeconds(startTimeStr);
|
||||
const endVal = parseTimeToSeconds(endTimeStr);
|
||||
const durationSec = (startVal !== null && endVal !== null) ? endVal - startVal : 0;
|
||||
const segmentTypes: { label: string; value: SkipType; icon: any }[] = [
|
||||
{ label: 'Intro', value: 'intro', icon: 'play-circle-outline' },
|
||||
{ label: 'Recap', value: 'recap', icon: 'replay' },
|
||||
{ label: 'Outro', value: 'outro', icon: 'stop-circle' },
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
|
||||
|
|
@ -144,13 +148,42 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
|
|||
style={[localStyles.modalContainer, { width: Math.min(width * 0.85, 380) }]}
|
||||
>
|
||||
<View style={localStyles.header}>
|
||||
<Text style={localStyles.title}>Submit Intro Timestamp</Text>
|
||||
<Text style={localStyles.title}>Submit Timestamps</Text>
|
||||
<TouchableOpacity onPress={onClose} style={localStyles.closeButton}>
|
||||
<Ionicons name="close" size={24} color="rgba(255,255,255,0.5)" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={localStyles.content}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={localStyles.content}>
|
||||
{/* Segment Type Selector */}
|
||||
<View>
|
||||
<Text style={localStyles.label}>Segment Type</Text>
|
||||
<View style={localStyles.typeRow}>
|
||||
{segmentTypes.map((type) => (
|
||||
<TouchableOpacity
|
||||
key={type.value}
|
||||
onPress={() => setSegmentType(type.value)}
|
||||
style={[
|
||||
localStyles.typeButton,
|
||||
segmentType === type.value && localStyles.typeButtonActive
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={type.icon}
|
||||
size={18}
|
||||
color={segmentType === type.value ? 'black' : 'rgba(255,255,255,0.6)'}
|
||||
/>
|
||||
<Text style={[
|
||||
localStyles.typeButtonText,
|
||||
segmentType === type.value && localStyles.typeButtonTextActive
|
||||
]}>
|
||||
{type.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Start Time Input */}
|
||||
<View style={localStyles.inputRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
|
|
@ -214,7 +247,7 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
|
|||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -239,6 +272,7 @@ const localStyles = StyleSheet.create({
|
|||
shadowOpacity: 0.5,
|
||||
shadowRadius: 15,
|
||||
elevation: 20,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -257,6 +291,34 @@ const localStyles = StyleSheet.create({
|
|||
content: {
|
||||
gap: 20,
|
||||
},
|
||||
typeRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
typeButton: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: 12,
|
||||
paddingVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
typeButtonActive: {
|
||||
backgroundColor: 'white',
|
||||
borderColor: 'white',
|
||||
},
|
||||
typeButtonText: {
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
typeButtonTextActive: {
|
||||
color: 'black',
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
|
|
@ -295,22 +357,6 @@ const localStyles = StyleSheet.create({
|
|||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
summaryBox: {
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginTop: 8,
|
||||
},
|
||||
summaryText: {
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: 14,
|
||||
marginBottom: 4,
|
||||
},
|
||||
hintText: {
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
fontSize: 12,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
|
|
@ -345,3 +391,4 @@ const localStyles = StyleSheet.create({
|
|||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -26,11 +26,21 @@ export interface IntroTimestamps {
|
|||
imdb_id: string;
|
||||
season: number;
|
||||
episode: number;
|
||||
start_sec: number;
|
||||
end_sec: number;
|
||||
start_ms: number;
|
||||
end_ms: number;
|
||||
confidence: number;
|
||||
intro?: {
|
||||
start_sec: number;
|
||||
end_sec: number;
|
||||
confidence: number;
|
||||
};
|
||||
recap?: {
|
||||
start_sec: number;
|
||||
end_sec: number;
|
||||
confidence: number;
|
||||
};
|
||||
outro?: {
|
||||
start_sec: number;
|
||||
end_sec: number;
|
||||
confidence: number;
|
||||
};
|
||||
}
|
||||
|
||||
async function getMalIdFromArm(imdbId: string): Promise<string | null> {
|
||||
|
|
@ -154,7 +164,7 @@ async function fetchFromAniSkip(malId: string, episode: number): Promise<SkipInt
|
|||
|
||||
async function fetchFromIntroDb(imdbId: string, season: number, episode: number): Promise<SkipInterval[]> {
|
||||
try {
|
||||
const response = await axios.get<IntroTimestamps>(`${INTRODB_API_URL}/intro`, {
|
||||
const response = await axios.get<IntroTimestamps>(`${INTRODB_API_URL}/segments`, {
|
||||
params: {
|
||||
imdb_id: imdbId,
|
||||
season,
|
||||
|
|
@ -163,26 +173,48 @@ async function fetchFromIntroDb(imdbId: string, season: number, episode: number)
|
|||
timeout: 5000,
|
||||
});
|
||||
|
||||
logger.log(`[IntroService] Found intro for ${imdbId} S${season}E${episode}:`, {
|
||||
start: response.data.start_sec,
|
||||
end: response.data.end_sec,
|
||||
confidence: response.data.confidence,
|
||||
});
|
||||
const intervals: SkipInterval[] = [];
|
||||
|
||||
return [{
|
||||
startTime: response.data.start_sec,
|
||||
endTime: response.data.end_sec,
|
||||
type: 'intro',
|
||||
provider: 'introdb'
|
||||
}];
|
||||
if (response.data.intro) {
|
||||
intervals.push({
|
||||
startTime: response.data.intro.start_sec,
|
||||
endTime: response.data.intro.end_sec,
|
||||
type: 'intro',
|
||||
provider: 'introdb'
|
||||
});
|
||||
}
|
||||
|
||||
if (response.data.recap) {
|
||||
intervals.push({
|
||||
startTime: response.data.recap.start_sec,
|
||||
endTime: response.data.recap.end_sec,
|
||||
type: 'recap',
|
||||
provider: 'introdb'
|
||||
});
|
||||
}
|
||||
|
||||
if (response.data.outro) {
|
||||
intervals.push({
|
||||
startTime: response.data.outro.start_sec,
|
||||
endTime: response.data.outro.end_sec,
|
||||
type: 'outro',
|
||||
provider: 'introdb'
|
||||
});
|
||||
}
|
||||
|
||||
if (intervals.length > 0) {
|
||||
logger.log(`[IntroService] Found ${intervals.length} segments for ${imdbId} S${season}E${episode}`);
|
||||
}
|
||||
|
||||
return intervals;
|
||||
} catch (error: any) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
// 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 segment data for ${imdbId} S${season}E${episode}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.error('[IntroService] Error fetching intro timestamps:', error?.message || error);
|
||||
logger.error('[IntroService] Error fetching segments from IntroDB:', error?.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -230,7 +262,8 @@ export async function submitIntro(
|
|||
season: number,
|
||||
episode: number,
|
||||
startTime: number, // in seconds
|
||||
endTime: number // in seconds
|
||||
endTime: number, // in seconds
|
||||
segmentType: SkipType = 'intro'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
if (!apiKey) {
|
||||
|
|
@ -240,8 +273,12 @@ export async function submitIntro(
|
|||
|
||||
const response = await axios.post(`${INTRODB_API_URL}/submit`, {
|
||||
imdb_id: imdbId,
|
||||
segment_type: segmentType === 'op' ? 'intro' : (segmentType === 'ed' ? 'outro' : segmentType),
|
||||
season,
|
||||
episode,
|
||||
start_sec: startTime,
|
||||
end_sec: endTime,
|
||||
// Keep start_ms/end_ms for backward compatibility if the server still expects it
|
||||
start_ms: Math.round(startTime * 1000),
|
||||
end_ms: Math.round(endTime * 1000),
|
||||
}, {
|
||||
|
|
@ -319,18 +356,24 @@ export async function getIntroTimestamps(
|
|||
imdbId: string,
|
||||
season: number,
|
||||
episode: number
|
||||
): Promise<IntroTimestamps | null> {
|
||||
): Promise<any | null> {
|
||||
const intervals = await fetchFromIntroDb(imdbId, season, episode);
|
||||
if (intervals.length > 0) {
|
||||
const intro = intervals.find(i => i.type === 'intro');
|
||||
if (intro) {
|
||||
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
|
||||
start_sec: intro.startTime,
|
||||
end_sec: intro.endTime,
|
||||
start_ms: intro.startTime * 1000,
|
||||
end_ms: intro.endTime * 1000,
|
||||
confidence: 1.0,
|
||||
intro: {
|
||||
start_sec: intro.startTime,
|
||||
end_sec: intro.endTime,
|
||||
confidence: 1.0
|
||||
}
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue