Dispatcharr/frontend/src/components/SeriesModal.jsx
nemesbak 5fbe38f8d2 fix(vod): respect relation_id when switching providers in SeriesModal/VODModal
The provider-info endpoints for both series and movies always returned
data from the highest-priority relation, ignoring the provider the user
had selected in the dropdown. Switching sources in the UI produced no
change in the episode list or movie details.

Root cause: the endpoints had no support for a relation_id query param,
and the frontend never passed one when the user changed the selection.

Fix (backend):
- series provider-info: accept ?relation_id=<id> and query that specific
  M3USeriesRelation; fall back to highest-priority when omitted
- movie provider-info: same treatment for M3UMovieRelation

Fix (frontend):
- api.getSeriesInfo / api.getMovieProviderInfo accept an optional
  relationId argument and append it to the query string
- useVODStore.fetchSeriesInfo / fetchMovieDetailsFromProvider forward
  the argument to the API layer
- SeriesModal.onChangeSelectedProvider re-fetches series info (episodes
  included) with the newly selected relation's id
- VODModal.onChangeSelectedProvider re-fetches movie details with the
  newly selected relation's id

Fixes #1250

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:39:04 +02:00

735 lines
24 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Flex,
Group,
Image,
Text,
Title,
Select,
Badge,
Loader,
Stack,
ActionIcon,
Modal,
Tabs,
Table,
Divider,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
TabsList,
TabsPanel,
TabsTab,
} from '@mantine/core';
import { Play, Copy } from 'lucide-react';
import { copyToClipboard } from '../utils';
import useVODStore from '../store/useVODStore';
import useVideoStore from '../store/useVideoStore';
import useSettingsStore from '../store/settings';
import {
formatDuration,
formatStreamLabel,
getEpisodeAirdate,
getEpisodeStreamUrl,
getTmdbUrlLink,
getYouTubeEmbedUrl,
groupEpisodesBySeason,
imdbUrl,
sortBySeasonNumber,
sortEpisodesList,
tmdbUrl,
} from '../utils/components/SeriesModalUtils.js';
import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx';
const Series = ({ displaySeries, onClickYouTubeTrailer }) => {
return (
<Flex gap="md">
{displaySeries.series_image || displaySeries.logo?.url ? (
<Box style={{ flexShrink: 0 }}>
<Image
src={displaySeries.series_image || displaySeries.logo.url}
width={200}
height={300}
alt={displaySeries.name}
fit="contain"
bdrs={8}
/>
</Box>
) : (
<Box
w={200}
h={300}
display="flex"
bdrs={8}
style={{
backgroundColor: '#404040',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Play size={48} color="#666" />
</Box>
)}
<Stack spacing="md" flex={1}>
<Title order={3}>{displaySeries.name}</Title>
{/* Original name if different */}
{displaySeries.o_name &&
displaySeries.o_name !== displaySeries.name && (
<Text size="sm" c="dimmed" fs="italic">
Original: {displaySeries.o_name}
</Text>
)}
<Group spacing="md">
{displaySeries.year && (
<Badge color="blue">{displaySeries.year}</Badge>
)}
{displaySeries.rating && (
<Badge color="yellow">{displaySeries.rating}</Badge>
)}
{displaySeries.age && (
<Badge color="orange">{displaySeries.age}</Badge>
)}
<Badge color="purple">Series</Badge>
{displaySeries.episode_count && (
<Badge color="gray">{displaySeries.episode_count} episodes</Badge>
)}
{/* imdb_id and tmdb_id badges */}
{displaySeries.imdb_id && (
<Badge
color="yellow"
component="a"
href={imdbUrl(displaySeries.imdb_id)}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
IMDb
</Badge>
)}
{displaySeries.tmdb_id && (
<Badge
color="cyan"
component="a"
href={tmdbUrl(displaySeries.tmdb_id, 'tv')}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
TMDb
</Badge>
)}
</Group>
{/* Release date */}
{displaySeries.release_date && (
<Text size="sm" c="dimmed">
<strong>Release Date:</strong> {displaySeries.release_date}
</Text>
)}
{displaySeries.genre && (
<Text size="sm" c="dimmed">
<strong>Genre:</strong> {displaySeries.genre}
</Text>
)}
{displaySeries.director && (
<Text size="sm" c="dimmed">
<strong>Director:</strong> {displaySeries.director}
</Text>
)}
{displaySeries.cast && (
<Text size="sm" c="dimmed">
<strong>Cast:</strong> {displaySeries.cast}
</Text>
)}
{displaySeries.country && (
<Text size="sm" c="dimmed">
<strong>Country:</strong> {displaySeries.country}
</Text>
)}
{/* Description */}
{displaySeries.description && (
<Box>
<Text size="sm" weight={500} mb={8}>
Description
</Text>
<Text size="sm">{displaySeries.description}</Text>
</Box>
)}
{/* Watch Trailer button if available */}
{displaySeries.youtube_trailer && (
<Button
variant="outline"
color="red"
mt="auto"
style={{ alignSelf: 'flex-start' }}
onClick={onClickYouTubeTrailer}
>
Watch Trailer
</Button>
)}
</Stack>
</Flex>
);
};
const Episode = ({ episode, displaySeries }) => {
return (
<Stack spacing="sm">
{/* Episode Image and Description Row */}
<Flex gap="md">
{/* Episode Image */}
{episode.movie_image && (
<Box style={{ flexShrink: 0 }}>
<Image
src={episode.movie_image}
width={120}
height={160}
alt={episode.name}
fit="cover"
bdrs={4}
/>
</Box>
)}
{/* Episode Description */}
<Box flex={1}>
{episode.description && (
<Box>
<Text size="sm" weight={500} mb={4}>
Description
</Text>
<Text size="sm" c="dimmed">
{episode.description}
</Text>
</Box>
)}
</Box>
</Flex>
{/* Additional Episode Details */}
<Group spacing="xl">
{episode.rating && (
<Box>
<Text size="xs" weight={500} c="dimmed" mb={2}>
Rating
</Text>
<Badge color="yellow" size="sm">
{episode.rating}
</Badge>
</Box>
)}
{/* IMDb and TMDb badges for episode */}
{(episode.imdb_id || displaySeries.tmdb_id) && (
<Box>
<Text size="xs" weight={500} c="dimmed" mb={2}>
Links
</Text>
{episode.imdb_id && (
<Badge
color="yellow"
component="a"
href={imdbUrl(episode.imdb_id)}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
IMDb
</Badge>
)}
{displaySeries.tmdb_id && (
<Badge
color="cyan"
component="a"
href={getTmdbUrlLink(displaySeries, episode)}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
TMDb
</Badge>
)}
</Box>
)}
{episode.director && (
<Box>
<Text size="xs" weight={500} c="dimmed" mb={2}>
Director
</Text>
<Text size="sm">{episode.director}</Text>
</Box>
)}
{episode.actors && (
<Box>
<Text size="xs" weight={500} c="dimmed" mb={2}>
Cast
</Text>
<Text size="sm" lineClamp={2}>
{episode.actors}
</Text>
</Box>
)}
</Group>
{/* Technical Details */}
{(episode.bitrate || episode.video || episode.audio) && (
<Box>
<Text size="xs" weight={500} c="dimmed" mb={4}>
Technical Details
</Text>
<Stack spacing={2}>
{episode.bitrate && episode.bitrate > 0 && (
<Text size="xs" c="dimmed">
<strong>Bitrate:</strong> {episode.bitrate} kbps
</Text>
)}
{episode.video && Object.keys(episode.video).length > 0 && (
<Text size="xs" c="dimmed">
<strong>Video:</strong>{' '}
{episode.video.codec_long_name || episode.video.codec_name}
{episode.video.width && episode.video.height
? `, ${episode.video.width}x${episode.video.height}`
: ''}
</Text>
)}
{episode.audio && Object.keys(episode.audio).length > 0 && (
<Text size="xs" c="dimmed">
<strong>Audio:</strong>{' '}
{episode.audio.codec_long_name || episode.audio.codec_name}
{episode.audio.channels
? `, ${episode.audio.channels} channels`
: ''}
</Text>
)}
</Stack>
</Box>
)}
{/* Provider Information */}
{episode.m3u_account && (
<Group spacing="md">
<Text size="xs" weight={500} c="dimmed">
Provider:
</Text>
<Badge color="blue" variant="light" size="sm">
{episode.m3u_account.name || episode.m3u_account}
</Badge>
</Group>
)}
</Stack>
);
};
const SeriesModal = ({ series, opened, onClose }) => {
const { fetchSeriesInfo, fetchSeriesProviders } = useVODStore();
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const [detailedSeries, setDetailedSeries] = useState(null);
const [loadingDetails, setLoadingDetails] = useState(false);
const [activeTab, setActiveTab] = useState(null);
const [expandedEpisode, setExpandedEpisode] = useState(null);
const [trailerModalOpened, setTrailerModalOpened] = useState(false);
const [trailerUrl, setTrailerUrl] = useState('');
const [providers, setProviders] = useState([]);
const [selectedProvider, setSelectedProvider] = useState(null);
const [loadingProviders, setLoadingProviders] = useState(false);
useEffect(() => {
if (opened && series) {
// Fetch detailed series info which now includes episodes
setLoadingDetails(true);
fetchSeriesInfo(series.id)
.then((details) => {
setDetailedSeries(details);
// Check if episodes were fetched
if (!details.episodes_fetched) {
// Episodes not yet fetched, may need to wait for background fetch
}
})
.catch((error) => {
console.warn(
'Failed to fetch series details, using basic info:',
error
);
setDetailedSeries(series); // Fallback to basic data
})
.finally(() => {
setLoadingDetails(false);
});
// Fetch available providers
setLoadingProviders(true);
fetchSeriesProviders(series.id)
.then((providersData) => {
setProviders(providersData);
// Set the first provider as default if none selected
if (providersData.length > 0 && !selectedProvider) {
setSelectedProvider(providersData[0]);
}
})
.catch((error) => {
console.error('Failed to fetch series providers:', error);
setProviders([]);
})
.finally(() => {
setLoadingProviders(false);
});
}
}, [opened, series, fetchSeriesInfo, fetchSeriesProviders, selectedProvider]);
useEffect(() => {
if (!opened) {
setDetailedSeries(null);
setLoadingDetails(false);
setProviders([]);
setSelectedProvider(null);
setLoadingProviders(false);
}
}, [opened]);
// Get episodes from the store based on the series ID
const seriesEpisodes = React.useMemo(() => {
if (!detailedSeries) return [];
// Try to get episodes from the fetched data
if (detailedSeries.episodesList) {
return sortEpisodesList(detailedSeries.episodesList);
}
// If no episodes in detailed series, return empty array
return [];
}, [detailedSeries]);
// Group episodes by season
const episodesBySeason = React.useMemo(() => {
return groupEpisodesBySeason(seriesEpisodes);
}, [seriesEpisodes]);
// Get available seasons sorted
const seasons = React.useMemo(() => {
return sortBySeasonNumber(episodesBySeason);
}, [episodesBySeason]);
// Update active tab when seasons change or modal opens
React.useEffect(() => {
if (seasons.length > 0) {
if (
!activeTab ||
!seasons.includes(parseInt(activeTab.replace('season-', '')))
) {
setActiveTab(`season-${seasons[0]}`);
}
}
}, [seasons, activeTab]);
// Reset tab when modal closes
React.useEffect(() => {
if (!opened) {
setActiveTab(null);
}
}, [opened]);
const handlePlayEpisode = (episode) => {
const streamUrl = getEpisodeStreamUrl(episode, selectedProvider, env_mode);
showVideo(streamUrl, 'vod', episode);
};
const handleCopyEpisodeLink = async (episode) => {
const streamUrl = getEpisodeStreamUrl(episode, selectedProvider, env_mode);
await copyToClipboard(streamUrl, {
successTitle: 'Link Copied!',
successMessage: 'Episode link copied to clipboard',
});
};
const handleEpisodeRowClick = (episode) => {
setExpandedEpisode(expandedEpisode === episode.id ? null : episode.id);
};
const onClickYouTubeTrailer = () => {
setTrailerUrl(getYouTubeEmbedUrl(displaySeries.youtube_trailer));
setTrailerModalOpened(true);
};
const onChangeSelectedProvider = (value) => {
const provider = providers.find((p) => p.id.toString() === value);
setSelectedProvider(provider);
if (provider) {
setLoadingDetails(true);
fetchSeriesInfo(series.id, provider.id)
.then((details) => setDetailedSeries(details))
.catch(() => {})
.finally(() => setLoadingDetails(false));
}
};
if (!series) return null;
// Use detailed data if available, otherwise use basic series data
const displaySeries = detailedSeries || series;
return (
<>
<Modal
opened={opened}
onClose={onClose}
title={displaySeries.name}
size="xl"
centered
>
<Box style={{ position: 'relative', minHeight: 400 }}>
{/* Backdrop image as background */}
{displaySeries.backdrop_path &&
displaySeries.backdrop_path.length > 0 && (
<>
<Image
src={displaySeries.backdrop_path[0]}
alt={`${displaySeries.name} backdrop`}
fit="cover"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
zIndex: 0,
borderRadius: 8,
filter: 'blur(2px) brightness(0.5)',
}}
/>
{/* Overlay for readability */}
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'linear-gradient(180deg, rgba(24,24,27,0.85) 60%, rgba(24,24,27,1) 100%)',
zIndex: 1,
borderRadius: 8,
}}
/>
</>
)}
{/* Modal content above backdrop */}
<Box style={{ position: 'relative', zIndex: 2 }}>
<Stack spacing="md">
{loadingDetails && (
<Group spacing="xs" mb={8}>
<Loader size="xs" />
<Text size="xs" color="dimmed">
Loading series details and episodes...
</Text>
</Group>
)}
{/* Series poster and basic info */}
<Series
displaySeries={displaySeries}
onClickYouTubeTrailer={onClickYouTubeTrailer}
/>
{/* Provider Information */}
<Box mt="md">
<Text size="sm" weight={500} mb={4}>
Stream Selection
{loadingProviders && (
<Loader size="xs" style={{ marginLeft: 8 }} />
)}
</Text>
{providers.length === 0 &&
!loadingProviders &&
displaySeries.m3u_account ? (
<Group spacing="md">
<Badge color="blue" variant="light">
{displaySeries.m3u_account.name}
</Badge>
</Group>
) : providers.length === 1 ? (
<Group spacing="md">
<Badge color="blue" variant="light">
{providers[0].m3u_account.name}
</Badge>
{providers[0].stream_id && (
<Badge color="orange" variant="outline" size="xs">
Stream {providers[0].stream_id}
</Badge>
)}
</Group>
) : providers.length > 1 ? (
<Select
data={providers.map((provider) => ({
value: provider.id.toString(),
label: formatStreamLabel(provider),
}))}
value={selectedProvider?.id?.toString() || ''}
onChange={(value) => onChangeSelectedProvider(value)}
placeholder="Select stream..."
style={{ maxWidth: 350 }}
disabled={loadingProviders}
/>
) : null}
</Box>
<Divider />
<Title order={4}>
Episodes
{seriesEpisodes.length > 0 && <> ({seriesEpisodes.length})</>}
</Title>
{loadingDetails ? (
<Flex justify="center" py="xl">
<Loader />
</Flex>
) : seasons.length > 0 ? (
<Tabs value={activeTab} onChange={setActiveTab}>
<TabsList>
{seasons.map((season) => (
<TabsTab key={season} value={`season-${season}`}>
Season {season}
</TabsTab>
))}
</TabsList>
{seasons.map((season) => (
<TabsPanel key={season} value={`season-${season}`} pt="md">
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '60px' }}>Ep</TableTh>
<TableTh>Title</TableTh>
<TableTh style={{ width: '80px' }}>Duration</TableTh>
<TableTh style={{ width: '60px' }}>Date</TableTh>
<TableTh style={{ width: '80px' }}>Action</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{episodesBySeason[season]?.map((episode) => (
<React.Fragment key={episode.id}>
<TableTr
style={{ cursor: 'pointer' }}
onClick={() => handleEpisodeRowClick(episode)}
>
<TableTd>
<Badge size="sm" variant="outline">
{episode.episode_number || '?'}
</Badge>
</TableTd>
<TableTd>
<Stack spacing={2}>
<Text size="sm" weight={500}>
{episode.name}
</Text>
{episode.genre && (
<Text size="xs" color="dimmed">
{episode.genre}
</Text>
)}
</Stack>
</TableTd>
<TableTd>
<Text size="xs" color="dimmed">
{formatDuration(episode.duration_secs)}
</Text>
</TableTd>
<TableTd>
<Text size="xs" color="dimmed">
{getEpisodeAirdate(episode)}
</Text>
</TableTd>
<TableTd>
<Group spacing="xs">
<ActionIcon
variant="filled"
color="blue"
size="sm"
disabled={
providers.length > 0 &&
!selectedProvider
}
onClick={(e) => {
e.stopPropagation();
handlePlayEpisode(episode);
}}
>
<Play size={12} />
</ActionIcon>
<ActionIcon
variant="outline"
color="gray"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleCopyEpisodeLink(episode);
}}
>
<Copy size={12} />
</ActionIcon>
</Group>
</TableTd>
</TableTr>
{expandedEpisode === episode.id && (
<TableTr>
<TableTd
colSpan={5}
p={16}
style={{
backgroundColor: '#2A2A2E',
}}
>
<Episode
episode={episode}
displaySeries={displaySeries}
/>
</TableTd>
</TableTr>
)}
</React.Fragment>
))}
</TableTbody>
</Table>
</TabsPanel>
))}
</Tabs>
) : (
<Text color="dimmed" align="center" py="xl">
No episodes found for this series.
</Text>
)}
</Stack>
</Box>
</Box>
</Modal>
{/* YouTube Trailer Modal */}
<YouTubeTrailerModal
opened={trailerModalOpened}
onClose={() => setTrailerModalOpened(false)}
trailerUrl={trailerUrl}
/>
</>
);
};
export default SeriesModal;