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.
This commit is contained in:
Uruk
2026-01-12 09:26:19 +01:00
committed by Gauvain
parent b7db06f53d
commit f211a9ce7a
47 changed files with 599 additions and 680 deletions

View File

@@ -0,0 +1,51 @@
import { useSegments } from "expo-router";
import type React from "react";
import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter";
import { useSeerr } from "@/hooks/useSeerr";
import {
COMPANY_LOGO_IMAGE_FILTER,
type Network,
} from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import type { Studio } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
const CompanySlide: React.FC<
{ data: Network[] | Studio[] } & SlideProps & ViewProps
> = ({ slide, data, ...props }) => {
const segments = useSegments();
const { seerrApi } = useSeerr();
const router = useRouter();
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
({ id, image, name }: Network | Studio) =>
router.push({
pathname: `/(auth)/(tabs)/${from}/seerr/company/${id}` as any,
params: { id, image, name, type: slide.type },
}),
[slide],
);
return (
<Slide
{...props}
slide={slide}
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, _index) => (
<TouchableOpacity className='mr-2' onPress={() => navigate(item)}>
<GenericSlideCard
className='w-28 rounded-lg overflow-hidden border border-neutral-900 p-4'
id={item.id.toString()}
url={seerrApi?.imageProxy(item.image, COMPANY_LOGO_IMAGE_FILTER)}
/>
</TouchableOpacity>
)}
/>
);
};
export default CompanySlide;

View File

@@ -0,0 +1,74 @@
import { sortBy } from "lodash";
import type React from "react";
import { useMemo } from "react";
import { View } from "react-native";
import CompanySlide from "@/components/seerr/discover/CompanySlide";
import GenreSlide from "@/components/seerr/discover/GenreSlide";
import MovieTvSlide from "@/components/seerr/discover/MovieTvSlide";
import RecentRequestsSlide from "@/components/seerr/discover/RecentRequestsSlide";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import { networks } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
import { studios } from "@/utils/jellyseerr/src/components/Discover/StudioSlider";
interface Props {
sliders?: DiscoverSlider[];
}
const Discover: React.FC<Props> = ({ sliders }) => {
const hasSliders = !!sliders;
const sortedSliders = useMemo(
() =>
sortBy(
(sliders ?? []).filter((s) => s.enabled),
"order",
"asc",
),
[sliders],
);
if (!hasSliders) return null;
return (
<View className='flex flex-col space-y-4 mb-8'>
{sortedSliders.map((slide) => {
switch (slide.type) {
case DiscoverSliderType.RECENT_REQUESTS:
return (
<RecentRequestsSlide
key={slide.id}
slide={slide}
contentContainerStyle={{ paddingBottom: 16 }}
/>
);
case DiscoverSliderType.NETWORKS:
return (
<CompanySlide key={slide.id} slide={slide} data={networks} />
);
case DiscoverSliderType.STUDIOS:
return <CompanySlide key={slide.id} slide={slide} data={studios} />;
case DiscoverSliderType.MOVIE_GENRES:
case DiscoverSliderType.TV_GENRES:
return <GenreSlide key={slide.id} slide={slide} />;
case DiscoverSliderType.TRENDING:
case DiscoverSliderType.POPULAR_MOVIES:
case DiscoverSliderType.UPCOMING_MOVIES:
case DiscoverSliderType.POPULAR_TV:
case DiscoverSliderType.UPCOMING_TV:
return (
<MovieTvSlide
key={slide.id}
slide={slide}
contentContainerStyle={{ paddingBottom: 16 }}
/>
);
default:
return null;
}
})}
</View>
);
};
export default Discover;

View File

@@ -0,0 +1,70 @@
import { Image, type ImageContentFit } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import type React from "react";
import { StyleSheet, View, type ViewProps } from "react-native";
import { Text } from "@/components/common/Text";
export const textShadowStyle = StyleSheet.create({
shadow: {
shadowColor: "#000",
shadowOffset: {
width: 1,
height: 1,
},
shadowOpacity: 1,
shadowRadius: 0.5,
elevation: 6,
},
});
const GenericSlideCard: React.FC<
{
id: string;
url?: string;
title?: string;
colors?: readonly [string, string, ...string[]];
contentFit?: ImageContentFit;
} & ViewProps
> = ({
id,
url,
title,
colors = ["#9333ea", "transparent"],
contentFit = "contain",
...props
}) => (
<>
<LinearGradient
colors={colors}
start={{ x: 0.5, y: 1.75 }}
end={{ x: 0.5, y: 0 }}
className='rounded-xl'
>
<View className='rounded-xl' {...props}>
<Image
key={id}
id={id}
source={url ? { uri: url } : null}
cachePolicy={"memory-disk"}
contentFit={contentFit}
style={{
aspectRatio: "4/3",
}}
/>
{title && (
<View className='absolute justify-center top-0 left-0 right-0 bottom-0 items-center'>
<Text
className='text-center font-bold'
style={textShadowStyle.shadow}
>
{title}
</Text>
</View>
)}
</View>
</LinearGradient>
</>
);
export default GenericSlideCard;

View File

@@ -0,0 +1,70 @@
import { useQuery } from "@tanstack/react-query";
import { useSegments } from "expo-router";
import type React from "react";
import { useCallback } from "react";
import { TouchableOpacity, type ViewProps } from "react-native";
import GenericSlideCard from "@/components/seerr/discover/GenericSlideCard";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import useRouter from "@/hooks/useAppRouter";
import { Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces";
import { genreColorMap } from "@/utils/jellyseerr/src/components/Discover/constants";
const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
const segments = useSegments();
const { seerrApi } = useSeerr();
const router = useRouter();
const from = (segments as string[])[2] || "(home)";
const navigate = useCallback(
(genre: GenreSliderItem) =>
router.push({
pathname: `/(auth)/(tabs)/${from}/seerr/genre/${genre.id}` as any,
params: { type: slide.type, name: genre.name },
}),
[slide],
);
const { data } = useQuery({
queryKey: ["seerr", "discover", slide.type, slide.id],
queryFn: async () => {
return seerrApi?.getGenreSliders(
slide.type === DiscoverSliderType.MOVIE_GENRES
? Endpoints.MOVIE
: Endpoints.TV,
);
},
enabled: !!seerrApi,
});
return (
data && (
<Slide
{...props}
slide={slide}
data={data}
keyExtractor={(item) => item.id.toString()}
renderItem={(item, _index) => (
<TouchableOpacity className='mr-2' onPress={() => navigate(item)}>
<GenericSlideCard
className='w-28 rounded-lg overflow-hidden border border-neutral-900'
id={item.id.toString()}
title={item.name}
colors={["transparent", "transparent"]}
contentFit={"cover"}
url={seerrApi?.imageProxy(
item.backdrops?.[0],
`w780_filter(duotone,${
genreColorMap[item.id] ?? genreColorMap[0]
})`,
)}
/>
</TouchableOpacity>
)}
/>
)
);
};
export default GenreSlide;

View File

@@ -0,0 +1,87 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { uniqBy } from "lodash";
import type React from "react";
import { useMemo } from "react";
import type { ViewProps } from "react-native";
import SeerrPoster from "@/components/posters/SeerrPoster";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import { type DiscoverEndpoint, Endpoints, useSeerr } from "@/hooks/useSeerr";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
slide,
...props
}) => {
const { seerrApi, isSeerrMovieOrTvResult } = useSeerr();
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["seerr", "discover", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined;
let params: any = {
page: Number(pageParam),
};
switch (slide.type) {
case DiscoverSliderType.TRENDING:
endpoint = Endpoints.DISCOVER_TRENDING;
break;
case DiscoverSliderType.POPULAR_MOVIES:
case DiscoverSliderType.UPCOMING_MOVIES:
endpoint = Endpoints.DISCOVER_MOVIES;
if (slide.type === DiscoverSliderType.UPCOMING_MOVIES)
params = {
...params,
primaryReleaseDateGte: new Date().toISOString().split("T")[0],
};
break;
case DiscoverSliderType.POPULAR_TV:
case DiscoverSliderType.UPCOMING_TV:
endpoint = Endpoints.DISCOVER_TV;
if (slide.type === DiscoverSliderType.UPCOMING_TV)
params = {
...params,
firstAirDateGte: new Date().toISOString().split("T")[0],
};
break;
}
return endpoint ? seerrApi?.discover(endpoint, params) : null;
},
initialPageParam: 1,
getNextPageParam: (lastPage, pages) =>
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
1,
enabled: !!seerrApi,
staleTime: 0,
});
const flatData = useMemo(
() =>
uniqBy(
data?.pages
?.filter((p) => p?.results.length)
.flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))),
"id",
),
[data],
);
return (
flatData &&
flatData?.length > 0 && (
<Slide
{...props}
slide={slide}
data={flatData}
keyExtractor={(item) => item!.id.toString()}
onEndReached={() => {
if (hasNextPage) fetchNextPage();
}}
renderItem={(item) => <SeerrPoster item={item} key={item?.id} />}
/>
)
);
};
export default MovieTvSlide;

View File

@@ -0,0 +1,93 @@
import { useQuery } from "@tanstack/react-query";
import type React from "react";
import type { ViewProps } from "react-native";
import SeerrPoster from "@/components/posters/SeerrPoster";
import Slide, { type SlideProps } from "@/components/seerr/discover/Slide";
import { useSeerr } from "@/hooks/useSeerr";
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import type { NonFunctionProperties } from "@/utils/jellyseerr/server/interfaces/api/common";
type ExtendedMediaRequest = NonFunctionProperties<MediaRequest> & {
profileName: string;
canRemove: boolean;
};
const RequestCard: React.FC<{ request: ExtendedMediaRequest }> = ({
request,
}) => {
const { seerrApi } = useSeerr();
const { data: details } = useQuery({
queryKey: [
"seerr",
"detail",
request.media.mediaType,
request.media.tmdbId,
],
queryFn: async () => {
return request.media.mediaType === MediaType.MOVIE
? seerrApi?.movieDetails(request.media.tmdbId)
: seerrApi?.tvDetails(request.media.tmdbId);
},
enabled: !!seerrApi,
refetchOnMount: true,
staleTime: 0,
});
const { data: refreshedRequest } = useQuery({
queryKey: ["seerr", "requests", request.media.mediaType, request.id],
queryFn: async () => seerrApi?.getRequest(request.id),
enabled: !!seerrApi,
refetchOnMount: true,
refetchInterval: 5000,
staleTime: 0,
});
return (
<SeerrPoster
horizontal
showDownloadInfo
item={details}
mediaRequest={refreshedRequest}
/>
);
};
const RecentRequestsSlide: React.FC<SlideProps & ViewProps> = ({
slide,
...props
}) => {
const { seerrApi } = useSeerr();
const { data: requests } = useQuery({
queryKey: ["seerr", "recent_requests"],
queryFn: async () => seerrApi?.requests(),
enabled: !!seerrApi,
refetchOnMount: true,
staleTime: 0,
});
return (
requests &&
requests.results.length > 0 && (
<Slide
{...props}
slide={slide}
data={
requests.results.map((item) => ({
...item,
profileName: item.profileName ?? "Unknown",
canRemove: Boolean(item.canRemove),
})) as ExtendedMediaRequest[]
}
keyExtractor={(item) => item.id.toString()}
renderItem={(item: ExtendedMediaRequest) => (
<RequestCard request={item} />
)}
/>
)
);
};
export default RecentRequestsSlide;

View File

@@ -0,0 +1,59 @@
import { FlashList } from "@shopify/flash-list";
import { t } from "i18next";
import type React from "react";
import type { PropsWithChildren } from "react";
import { View, type ViewProps, type ViewStyle } from "react-native";
import { Text } from "@/components/common/Text";
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
export interface SlideProps {
slide: DiscoverSlider;
contentContainerStyle?: ViewStyle;
}
interface Props<T> extends SlideProps {
data: T[];
renderItem: (
item: T,
index: number,
) => React.ComponentType<any> | React.ReactElement | null | undefined;
keyExtractor: (item: T) => string;
onEndReached?: (() => void) | null | undefined;
}
const Slide = <T,>({
data,
slide,
renderItem,
keyExtractor,
onEndReached,
contentContainerStyle,
...props
}: PropsWithChildren<Props<T> & ViewProps>) => {
return (
<View {...props}>
<Text className='font-bold text-lg mb-2 px-4'>
{t(`search.${DiscoverSliderType[slide.type].toString().toLowerCase()}`)}
</Text>
<FlashList
horizontal
contentContainerStyle={{
paddingHorizontal: 16,
...(contentContainerStyle ? contentContainerStyle : {}),
}}
showsHorizontalScrollIndicator={false}
keyExtractor={keyExtractor}
data={data}
onEndReachedThreshold={1}
onEndReached={onEndReached}
//@ts-expect-error
renderItem={({ item, index }) =>
item ? renderItem(item, index) : null
}
/>
</View>
);
};
export default Slide;