mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
fix: correct dependency arrays and add null checks
Fixes missing dependencies in useMemo and useCallback hooks to prevent stale closures and potential bugs. Adds null/undefined guards before navigation in music components to prevent crashes when attempting to navigate with missing IDs. Corrects query key from "company" to "genre" in genre page to ensure proper cache invalidation. Updates Jellyseerr references to Seerr throughout documentation and error messages for consistency. Improves type safety by adding error rejection handling in SeerrApi and memoizing components to optimize re-renders.
This commit is contained in:
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Jellyseerr integration.
|
||||
Streamyfin is a cross-platform Jellyfin video streaming client built with Expo (React Native). It supports mobile (iOS/Android) and TV platforms, with features including offline downloads, Chromecast support, and Seerr integration.
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function CompanyPage() {
|
||||
),
|
||||
"id",
|
||||
) ?? [],
|
||||
[data],
|
||||
[data, isSeerrMovieOrTvResult],
|
||||
);
|
||||
|
||||
const backdrops = useMemo(
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function GenrePage() {
|
||||
};
|
||||
|
||||
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||
queryKey: ["seerr", "company", type, genreId],
|
||||
queryKey: ["seerr", "genre", type, genreId],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const params: any = {
|
||||
page: Number(pageParam),
|
||||
|
||||
@@ -26,7 +26,7 @@ export const TrackSheet: React.FC<Props> = ({
|
||||
|
||||
const streams = useMemo(
|
||||
() => source?.MediaStreams?.filter((x) => x.Type === streamType),
|
||||
[source],
|
||||
[source, streamType],
|
||||
);
|
||||
|
||||
const selectedSteam = useMemo(
|
||||
|
||||
@@ -51,7 +51,7 @@ export const LibraryItemCard: React.FC<Props> = ({ library, ...props }) => {
|
||||
api,
|
||||
item: library,
|
||||
}),
|
||||
[library],
|
||||
[api, library],
|
||||
);
|
||||
|
||||
const itemType = useMemo(() => {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const MusicAlbumCard: React.FC<Props> = ({ album, width = 130 }) => {
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!album.Id) return;
|
||||
router.push(`/music/album/${album.Id}`);
|
||||
}, [router, album.Id]);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export const MusicAlbumRowCard: React.FC<Props> = ({ album }) => {
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!album.Id) return;
|
||||
router.push(`/music/album/${album.Id}`);
|
||||
}, [router, album.Id]);
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export const MusicArtistCard: React.FC<Props> = ({ artist }) => {
|
||||
);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!artist.Id) return;
|
||||
router.push(`/music/artist/${artist.Id}`);
|
||||
}, [router, artist.Id]);
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ const SeerrPoster: React.FC<Props> = ({
|
||||
className='absolute right-1 top-1 text-right bg-black border border-neutral-800/50'
|
||||
text={mediaRequest?.requestedBy.displayName}
|
||||
/>
|
||||
{requestedSeasons.length > 0 && (
|
||||
{(requestedSeasons?.length ?? 0) > 0 && (
|
||||
<Tags
|
||||
className='absolute bottom-1 left-0.5 w-32'
|
||||
tagProps={{
|
||||
|
||||
@@ -20,12 +20,13 @@ const CastSlide: React.FC<
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
data={details?.credits.cast}
|
||||
estimatedItemSize={112}
|
||||
ItemSeparatorComponent={() => <View className='w-2' />}
|
||||
keyExtractor={(item) => item?.id?.toString()}
|
||||
keyExtractor={(item) => item?.id?.toString() ?? ""}
|
||||
contentContainerStyle={{ paddingHorizontal: 16 }}
|
||||
renderItem={({ item }) => (
|
||||
<PersonPoster
|
||||
id={item.id.toString()}
|
||||
id={item?.id?.toString() ?? ""}
|
||||
posterPath={item.profilePath}
|
||||
name={item.name}
|
||||
subName={item.character}
|
||||
|
||||
@@ -58,7 +58,7 @@ const DetailFacts: React.FC<
|
||||
(details as MovieDetails)?.releases?.results.find(
|
||||
(r: TmdbRelease) => r.iso_3166_1 === region,
|
||||
)?.release_dates as TmdbRelease["release_dates"],
|
||||
[details],
|
||||
[details, region],
|
||||
);
|
||||
|
||||
// Release date types:
|
||||
@@ -82,7 +82,7 @@ const DetailFacts: React.FC<
|
||||
if (firstAirDate) {
|
||||
return new Date(firstAirDate).toLocaleDateString(locale, dateOpts);
|
||||
}
|
||||
}, [details]);
|
||||
}, [details, locale]);
|
||||
|
||||
const nextAirDate = useMemo(() => {
|
||||
const firstAirDate = (details as TvDetails)?.firstAirDate;
|
||||
@@ -90,7 +90,7 @@ const DetailFacts: React.FC<
|
||||
if (nextAirDate && firstAirDate !== nextAirDate) {
|
||||
return new Date(nextAirDate).toLocaleDateString(locale, dateOpts);
|
||||
}
|
||||
}, [details]);
|
||||
}, [details, locale]);
|
||||
|
||||
const revenue = useMemo(
|
||||
() =>
|
||||
@@ -98,7 +98,7 @@ const DetailFacts: React.FC<
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}),
|
||||
[details],
|
||||
[details, locale],
|
||||
);
|
||||
|
||||
const budget = useMemo(
|
||||
@@ -107,7 +107,7 @@ const DetailFacts: React.FC<
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}),
|
||||
[details],
|
||||
[details, locale],
|
||||
);
|
||||
|
||||
const streamingProviders = useMemo(
|
||||
@@ -115,7 +115,7 @@ const DetailFacts: React.FC<
|
||||
details?.watchProviders?.find(
|
||||
(provider) => provider.iso_3166_1 === region,
|
||||
)?.flatrate,
|
||||
[details],
|
||||
[details, region],
|
||||
);
|
||||
|
||||
const networks = useMemo(() => (details as TvDetails)?.networks, [details]);
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
}
|
||||
// Dev note might be a good idea to standardize skeletons across the app and have one "file" for it.
|
||||
export const GridSkeleton: React.FC<Props> = ({ index }) => {
|
||||
export const GridSkeleton = React.memo(() => {
|
||||
return (
|
||||
<View
|
||||
key={index}
|
||||
className='flex flex-col mr-2 h-auto'
|
||||
style={{ width: "30.5%" }}
|
||||
>
|
||||
<View className='flex flex-col mr-2 h-auto' style={{ width: "30.5%" }}>
|
||||
<View className='relative rounded-lg overflow-hidden border border-neutral-900 w-full mt-4 aspect-[10/15] bg-neutral-800' />
|
||||
<View className='mt-2 flex flex-col w-full'>
|
||||
<View className='h-4 bg-neutral-800 rounded mb-1' />
|
||||
@@ -18,4 +12,4 @@ export const GridSkeleton: React.FC<Props> = ({ index }) => {
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -40,6 +40,8 @@ const PersonPoster: React.FC<Props & ViewProps> = ({
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default PersonPoster;
|
||||
|
||||
@@ -38,7 +38,16 @@ const RequestModal = forwardRef<
|
||||
Props & Omit<ViewProps, "id">
|
||||
>(
|
||||
(
|
||||
{ id, title, requestBody, type, isAnime = false, onRequested, onDismiss },
|
||||
{
|
||||
id,
|
||||
title,
|
||||
requestBody,
|
||||
type,
|
||||
isAnime = false,
|
||||
is4k,
|
||||
onRequested,
|
||||
onDismiss,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { seerrApi, seerrUser, requestMedia } = useSeerr();
|
||||
@@ -258,7 +267,8 @@ const RequestModal = forwardRef<
|
||||
|
||||
const request = useCallback(() => {
|
||||
const body = {
|
||||
is4k: defaultService?.is4k || defaultServiceDetails?.server.is4k,
|
||||
is4k:
|
||||
is4k ?? defaultService?.is4k ?? defaultServiceDetails?.server.is4k,
|
||||
profileId: defaultProfile?.id,
|
||||
rootFolder: defaultFolder?.path,
|
||||
tags: defaultTags.map((t) => t.id),
|
||||
@@ -274,11 +284,18 @@ const RequestModal = forwardRef<
|
||||
onRequested,
|
||||
);
|
||||
}, [
|
||||
is4k,
|
||||
defaultService?.is4k,
|
||||
defaultServiceDetails?.server.is4k,
|
||||
requestBody,
|
||||
requestOverrides,
|
||||
defaultProfile,
|
||||
defaultFolder,
|
||||
defaultTags,
|
||||
requestMedia,
|
||||
seasonTitle,
|
||||
title,
|
||||
onRequested,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -113,7 +113,7 @@ export const SeerrIndexPage: React.FC<Props> = ({
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[seerrResults, sortingType, order],
|
||||
[seerrResults, sortingType, order, searchQuery],
|
||||
);
|
||||
|
||||
const seerrTvResults = useMemo(
|
||||
@@ -125,7 +125,7 @@ export const SeerrIndexPage: React.FC<Props> = ({
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[seerrResults, sortingType, order],
|
||||
[seerrResults, sortingType, order, searchQuery],
|
||||
);
|
||||
|
||||
const seerrPersonResults = useMemo(
|
||||
@@ -137,7 +137,7 @@ export const SeerrIndexPage: React.FC<Props> = ({
|
||||
],
|
||||
order || "desc",
|
||||
),
|
||||
[seerrResults, sortingType, order],
|
||||
[seerrResults, sortingType, order, searchQuery],
|
||||
);
|
||||
|
||||
if (!searchQuery.length)
|
||||
|
||||
@@ -26,7 +26,7 @@ const CompanySlide: React.FC<
|
||||
pathname: `/(auth)/(tabs)/${from}/seerr/company/${id}` as any,
|
||||
params: { id, image, name, type: slide.type },
|
||||
}),
|
||||
[slide],
|
||||
[router, from, slide.type],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,6 @@ const Discover: React.FC<Props> = ({ sliders }) => {
|
||||
sortBy(
|
||||
(sliders ?? []).filter((s) => s.enabled),
|
||||
"order",
|
||||
"asc",
|
||||
),
|
||||
[sliders],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image, type ImageContentFit } from "expo-image";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import type React from "react";
|
||||
import React from "react";
|
||||
import { StyleSheet, View, type ViewProps } from "react-native";
|
||||
import { Text } from "@/components/common/Text";
|
||||
|
||||
@@ -67,4 +67,4 @@ const GenericSlideCard: React.FC<
|
||||
</>
|
||||
);
|
||||
|
||||
export default GenericSlideCard;
|
||||
export default React.memo(GenericSlideCard);
|
||||
|
||||
@@ -23,9 +23,8 @@ const GenreSlide: React.FC<SlideProps & ViewProps> = ({ slide, ...props }) => {
|
||||
pathname: `/(auth)/(tabs)/${from}/seerr/genre/${genre.id}` as any,
|
||||
params: { type: slide.type, name: genre.name },
|
||||
}),
|
||||
[slide],
|
||||
[router, from, slide.type],
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["seerr", "discover", slide.type, slide.id],
|
||||
queryFn: async () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ const MovieTvSlide: React.FC<SlideProps & ViewProps> = ({
|
||||
.flatMap((p) => p?.results.filter((r) => isSeerrMovieOrTvResult(r))),
|
||||
"id",
|
||||
),
|
||||
[data],
|
||||
[data, isSeerrMovieOrTvResult],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,10 +14,7 @@ export interface SlideProps {
|
||||
|
||||
interface Props<T> extends SlideProps {
|
||||
data: T[];
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
) => React.ComponentType<any> | React.ReactElement | null | undefined;
|
||||
renderItem: (item: T, index: number) => React.ReactElement | null;
|
||||
keyExtractor: (item: T) => string;
|
||||
onEndReached?: (() => void) | null | undefined;
|
||||
}
|
||||
|
||||
@@ -424,6 +424,7 @@ export class SeerrApi {
|
||||
},
|
||||
(error) => {
|
||||
console.error("Seerr request error", error);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -448,7 +449,7 @@ export const useSeerr = () => {
|
||||
clearSeerrStorageData();
|
||||
setSeerrUser(undefined);
|
||||
updateSettings({ seerrServerUrl: undefined });
|
||||
}, []);
|
||||
}, [setSeerrUser, updateSettings]);
|
||||
|
||||
const requestMedia = useCallback(
|
||||
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
||||
@@ -474,18 +475,20 @@ export const useSeerr = () => {
|
||||
}
|
||||
});
|
||||
},
|
||||
[seerrApi],
|
||||
[seerrApi, queryClient],
|
||||
);
|
||||
|
||||
const isSeerrMovieOrTvResult = (
|
||||
items: any | null | undefined,
|
||||
): items is MovieResult | TvResult => {
|
||||
return (
|
||||
items &&
|
||||
Object.hasOwn(items, "mediaType") &&
|
||||
(items.mediaType === MediaType.MOVIE || items.mediaType === MediaType.TV)
|
||||
);
|
||||
};
|
||||
const isSeerrMovieOrTvResult = useCallback(
|
||||
(items: any | null | undefined): items is MovieResult | TvResult => {
|
||||
return (
|
||||
items &&
|
||||
Object.hasOwn(items, "mediaType") &&
|
||||
(items.mediaType === MediaType.MOVIE ||
|
||||
items.mediaType === MediaType.TV)
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getTitle = (
|
||||
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
||||
|
||||
@@ -176,7 +176,7 @@ function runTypeCheck() {
|
||||
} catch (error) {
|
||||
const errorOutput = (error && (error.stderr || error.stdout)) || "";
|
||||
|
||||
// Filter out jellyseerr utils errors - this is a third-party git submodule
|
||||
// Filter out seerr utils errors - this is a third-party git submodule
|
||||
// that generates a large volume of known type errors
|
||||
const filteredLines = errorOutput.split("\n").filter((line) => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
@@ -719,7 +719,7 @@
|
||||
"requested_by": "Requested by {{user}}",
|
||||
"unknown_user": "Unknown User",
|
||||
"toasts": {
|
||||
"seer_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"seerr_does_not_meet_requirements": "Seerr server does not meet minimum version requirements! Please update to at least 2.0.0",
|
||||
"seerr_test_failed": "Seerr test failed. Please try again.",
|
||||
"failed_to_test_seerr_server_url": "Failed to test Seerr server URL",
|
||||
"issue_submitted": "Issue Submitted!",
|
||||
|
||||
Reference in New Issue
Block a user