Files
streamyfin/components/series/SeerrSeasons.tsx
Uruk 4a75e8f551 refactor: rename Jellyseerr to Seerr throughout codebase
Updates branding and naming conventions to use "Seerr" instead of "Jellyseerr" across all files, components, hooks, and translations.

Renames files, functions, classes, variables, and UI text to reflect the new naming convention while maintaining identical functionality. Updates asset references including logo and screenshot images.

Changes API class name, storage keys, atom names, and all related utilities to use "Seerr" prefix. Modifies translation keys and user-facing text to match the rebrand.
2026-01-12 09:26:19 +01:00

338 lines
10 KiB
TypeScript

import { Ionicons } from "@expo/vector-icons";
import { FlashList } from "@shopify/flash-list";
import {
type QueryObserverResult,
type RefetchOptions,
useQuery,
} from "@tanstack/react-query";
import { Image } from "expo-image";
import { t } from "i18next";
import { orderBy } from "lodash";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { Alert, TouchableOpacity, View } from "react-native";
import { HorizontalScroll } from "@/components/common/HorizontalScroll";
import { Text } from "@/components/common/Text";
import { Tags } from "@/components/GenreTags";
import { RoundButton } from "@/components/RoundButton";
import { dateOpts } from "@/components/seerr/DetailFacts";
import { textShadowStyle } from "@/components/seerr/discover/GenericSlideCard";
import SeerrStatusIcon from "@/components/seerr/SeerrStatusIcon";
import { useSeerr } from "@/hooks/useSeerr";
import {
MediaStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type Season from "@/utils/jellyseerr/server/entity/Season";
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import { Loader } from "../Loader";
const SeerrSeasonEpisodes: React.FC<{
details: TvDetails;
seasonNumber: number;
}> = ({ details, seasonNumber }) => {
const { seerrApi } = useSeerr();
const { data: seasonWithEpisodes, isLoading } = useQuery({
queryKey: ["seerr", details.id, "season", seasonNumber],
queryFn: async () => seerrApi?.tvSeason(details.id, seasonNumber),
enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
});
return (
<HorizontalScroll
horizontal
loading={isLoading}
showsHorizontalScrollIndicator={false}
data={seasonWithEpisodes?.episodes}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, index) => (
<RenderItem key={index} item={item} index={index} />
)}
/>
);
};
const RenderItem = ({ item }: any) => {
const { seerrApi, seerrRegion: region, seerrLocale: locale } = useSeerr();
const [imageError, setImageError] = useState(false);
const upcomingAirDate = useMemo(() => {
const airDate = item.airDate;
if (airDate) {
const airDateObj = new Date(airDate);
if (new Date() < airDateObj) {
return airDateObj.toLocaleDateString(`${locale}-${region}`, dateOpts);
}
}
}, [item, locale, region]);
return (
<View className='flex flex-col w-44 mt-2'>
<View className='relative aspect-video rounded-lg overflow-hidden border border-neutral-800'>
{!imageError ? (
<>
<Image
key={item.id}
id={item.id}
source={{
uri: seerrApi?.imageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit='cover'
className='w-full h-full'
onError={(_e) => {
setImageError(true);
}}
/>
{upcomingAirDate && (
<View className='absolute justify-center bottom-0 right-0.5 items-center'>
<View className='rounded-full bg-purple-600/30 p-1'>
<Text
className='text-center text-xs'
style={textShadowStyle.shadow}
>
{upcomingAirDate}
</Text>
</View>
</View>
)}
</>
) : (
<View className='flex flex-col w-full h-full items-center justify-center border border-neutral-800 bg-neutral-900'>
<Ionicons
name='image-outline'
size={24}
color='white'
style={{ opacity: 0.4 }}
/>
</View>
)}
</View>
<View className='shrink mt-1'>
<Text numberOfLines={2} className=''>
{item.name}
</Text>
<Text numberOfLines={1} className='text-xs text-neutral-500'>
{`S${item.seasonNumber}:E${item.episodeNumber}`}
</Text>
</View>
<Text numberOfLines={3} className='text-xs text-neutral-500 shrink'>
{item.overview}
</Text>
</View>
);
};
const SeerrSeasons: React.FC<{
isLoading: boolean;
details?: TvDetails;
hasAdvancedRequest?: boolean;
onAdvancedRequest?: (data: MediaRequestBody) => void;
refetch: (
options?: RefetchOptions | undefined,
) => Promise<
QueryObserverResult<TvDetails | MovieDetails | undefined, Error>
>;
}> = ({
isLoading,
details,
refetch,
hasAdvancedRequest,
onAdvancedRequest,
}) => {
const { seerrApi, requestMedia } = useSeerr();
const [seasonStates, setSeasonStates] = useState<{ [key: number]: boolean }>(
{},
);
const seasons = useMemo(() => {
if (!details) return [];
const mediaInfoSeasons = details.mediaInfo?.seasons?.filter(
(s: Season) => s.seasonNumber !== 0,
);
const requestedSeasons =
details.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) ??
[];
return (
details.seasons?.map((season) => ({
...season,
status:
mediaInfoSeasons?.find(
(mediaSeason: Season) =>
mediaSeason.seasonNumber === season.seasonNumber,
)?.status ??
requestedSeasons?.find(
(s: Season) => s.seasonNumber === season.seasonNumber,
)?.status ??
MediaStatus.UNKNOWN,
})) ?? []
);
}, [details]);
const allSeasonsAvailable = useMemo(
() => seasons.every((season) => season.status === MediaStatus.AVAILABLE),
[seasons],
);
const requestAll = useCallback(() => {
if (details && seerrApi) {
const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: seasons
.filter(
(s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0,
)
.map((s) => s.seasonNumber),
};
if (hasAdvancedRequest) {
return onAdvancedRequest?.(body);
}
requestMedia(details.name, body, refetch);
}
}, [
seerrApi,
seasons,
details,
hasAdvancedRequest,
onAdvancedRequest,
requestMedia,
refetch,
]);
const promptRequestAll = useCallback(
() =>
Alert.alert(
t("seerr.confirm"),
t("seerr.are_you_sure_you_want_to_request_all_seasons"),
[
{
text: t("seerr.cancel"),
style: "cancel",
},
{
text: t("seerr.yes"),
onPress: requestAll,
},
],
),
[requestAll],
);
const requestSeason = useCallback(
async (canRequest: boolean, seasonNumber: number) => {
if (canRequest && details) {
const body: MediaRequestBody = {
mediaId: details.id,
mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId,
seasons: [seasonNumber],
};
if (hasAdvancedRequest) {
return onAdvancedRequest?.(body);
}
requestMedia(`${details.name}, Season ${seasonNumber}`, body, refetch);
}
},
[requestMedia, hasAdvancedRequest, onAdvancedRequest, refetch, details],
);
if (!details) return null;
if (isLoading)
return (
<View>
<View className='flex flex-row justify-between items-end px-4'>
<Text className='text-lg font-bold mb-2'>
{t("item_card.seasons")}
</Text>
{!allSeasonsAvailable && (
<RoundButton className='mb-2 pa-2' onPress={promptRequestAll}>
<Ionicons name='bag-add' color='white' size={26} />
</RoundButton>
)}
</View>
<Loader />
</View>
);
return (
<FlashList
data={orderBy(
seasons.filter((s) => s.seasonNumber !== 0),
"seasonNumber",
"desc",
)}
ListHeaderComponent={() => (
<View className='flex flex-row justify-between items-end px-4'>
<Text className='text-lg font-bold mb-2'>
{t("item_card.seasons")}
</Text>
{!allSeasonsAvailable && (
<RoundButton className='mb-2 pa-2' onPress={promptRequestAll}>
<Ionicons name='bag-add' color='white' size={26} />
</RoundButton>
)}
</View>
)}
ItemSeparatorComponent={() => <View className='h-2' />}
renderItem={({ item: season }) => (
<>
<TouchableOpacity
onPress={() =>
setSeasonStates((prevState) => ({
...prevState,
[season.seasonNumber]: !prevState?.[season.seasonNumber],
}))
}
className='px-4'
>
<View
className='flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4'
key={season.id}
>
<Tags
textClass=''
tags={[
t("seerr.season_number", {
season_number: season.seasonNumber,
}),
t("seerr.number_episodes", {
episode_number: season.episodeCount,
}),
]}
/>
{[0].map(() => {
const canRequest = season.status === MediaStatus.UNKNOWN;
return (
<SeerrStatusIcon
key={0}
onPress={() =>
requestSeason(canRequest, season.seasonNumber)
}
className={canRequest ? "bg-gray-700/40" : undefined}
mediaStatus={season.status}
showRequestIcon={canRequest}
/>
);
})}
</View>
</TouchableOpacity>
{seasonStates?.[season.seasonNumber] && (
<SeerrSeasonEpisodes
key={season.seasonNumber}
details={details}
seasonNumber={season.seasonNumber}
/>
)}
</>
)}
/>
);
};
export default SeerrSeasons;