Basic Jellyseerr discover page

This commit is contained in:
herrrta
2024-12-29 10:07:46 -05:00
parent c6b58c5c28
commit cbce83e109
8 changed files with 346 additions and 162 deletions

View File

@@ -34,6 +34,11 @@ import {useJellyseerr} from "@/hooks/useJellyseerr";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import {MediaType} from "@/utils/jellyseerr/server/constants/media";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {Tag} from "@/components/GenreTags";
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
import {sortBy} from "lodash";
type SearchType = 'Library' | 'Discover';
const exampleSearches = [
"Lord of the rings",
@@ -50,6 +55,7 @@ export default function search() {
const { q, prev } = params as { q: string; prev: Href<string> };
const [searchType, setSearchType] = useState<SearchType>("Library");
const [search, setSearch] = useState<string>("");
const [debouncedSearch] = useDebounce(search, 500);
@@ -138,10 +144,10 @@ export default function search() {
query: debouncedSearch,
types: ["Movie"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: jellyseerrResults, isFetching: r1 } = useQuery({
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
queryKey: ["search", "jellyseerrResults", debouncedSearch],
queryFn: async () => {
const response = await jellyseerrApi?.search({
@@ -152,7 +158,13 @@ export default function search() {
return response?.results;
},
enabled: !!jellyseerrApi && debouncedSearch.length > 0,
enabled: !!jellyseerrApi && searchType === "Discover" && debouncedSearch.length > 0,
});
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
queryFn: async () => jellyseerrApi?.discoverSettings(),
enabled: !!jellyseerrApi && searchType === "Discover" && debouncedSearch.length == 0,
});
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(() =>
@@ -172,7 +184,7 @@ export default function search() {
query: debouncedSearch,
types: ["Series"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: episodes, isFetching: l3 } = useQuery({
@@ -182,7 +194,7 @@ export default function search() {
query: debouncedSearch,
types: ["Episode"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: collections, isFetching: l7 } = useQuery({
@@ -192,7 +204,7 @@ export default function search() {
query: debouncedSearch,
types: ["BoxSet"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: actors, isFetching: l8 } = useQuery({
@@ -202,7 +214,7 @@ export default function search() {
query: debouncedSearch,
types: ["Person"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: artists, isFetching: l4 } = useQuery({
@@ -212,7 +224,7 @@ export default function search() {
query: debouncedSearch,
types: ["MusicArtist"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: albums, isFetching: l5 } = useQuery({
@@ -222,7 +234,7 @@ export default function search() {
query: debouncedSearch,
types: ["MusicAlbum"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const { data: songs, isFetching: l6 } = useQuery({
@@ -232,7 +244,7 @@ export default function search() {
query: debouncedSearch,
types: ["Audio"],
}),
enabled: debouncedSearch.length > 0,
enabled: searchType === "Library" && debouncedSearch.length > 0,
});
const noResults = useMemo(() => {
@@ -251,8 +263,8 @@ export default function search() {
}, [artists, episodes, albums, songs, movies, series, collections, actors, jellyseerrResults]);
const loading = useMemo(() => {
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8;
}, [l1, l2, l3, l4, l5, l6, l7, l8]);
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
}, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
return (
<>
@@ -277,6 +289,18 @@ export default function search() {
/>
</View>
)}
{jellyseerrApi && (
<View className="flex flex-row flex-wrap space-x-2 px-4">
<TouchableOpacity onPress={() => setSearchType('Library')}>
<Tag text="Library" textClass="p-1"
className={searchType === "Library" ? "bg-neutral-600" : undefined}/>
</TouchableOpacity>
<TouchableOpacity onPress={() => setSearchType('Discover')}>
<Tag text="Discover" textClass="p-1"
className={searchType === "Discover" ? "bg-neutral-600" : undefined}/>
</TouchableOpacity>
</View>
)}
{!!q && (
<View className="px-4 flex flex-col space-y-2">
<Text className="text-neutral-500 ">
@@ -284,144 +308,153 @@ export default function search() {
</Text>
</View>
)}
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28 mr-2"
item={item}
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
header="Request Movies"
items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
header="Series"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<SeriesPoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
header="Request Series"
items={jellyseerrTvResults}
renderItem={(item: TvResult) => (
<JellyseerrPoster item={item} key={item.id} />
)}
/>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
header="Episodes"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44 mr-2"
>
<ContinueWatchingPoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
header="Collections"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} key={item.Id} />
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
header="Actors"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId} />
<ItemCardText item={item} />
</TouchableItemRouter>
)}
/>
{searchType === "Library" && (
<>
<SearchItemWrapper
header="Movies"
ids={movies?.map((m) => m.Id!)}
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
className="flex flex-col w-28 mr-2"
item={item}
>
<MoviePoster item={item} key={item.Id}/>
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={series?.map((m) => m.Id!)}
header="Series"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<SeriesPoster item={item} key={item.Id}/>
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
<Text className="opacity-50 text-xs">
{item.ProductionYear}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={episodes?.map((m) => m.Id!)}
header="Episodes"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-44 mr-2"
>
<ContinueWatchingPoster item={item}/>
<ItemCardText item={item}/>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={collections?.map((m) => m.Id!)}
header="Collections"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
key={item.Id}
item={item}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item} key={item.Id}/>
<Text numberOfLines={2} className="mt-2">
{item.Name}
</Text>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={actors?.map((m) => m.Id!)}
header="Actors"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<MoviePoster item={item}/>
<ItemCardText item={item}/>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={artists?.map((m) => m.Id!)}
header="Artists"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id}/>
<ItemCardText item={item}/>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={albums?.map((m) => m.Id!)}
header="Albums"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.Id}/>
<ItemCardText item={item}/>
</TouchableItemRouter>
)}
/>
<SearchItemWrapper
ids={songs?.map((m) => m.Id!)}
header="Songs"
renderItem={(item: BaseItemDto) => (
<TouchableItemRouter
item={item}
key={item.Id}
className="flex flex-col w-28 mr-2"
>
<AlbumCover id={item.AlbumId}/>
<ItemCardText item={item}/>
</TouchableItemRouter>
)}
/>
</>
)}
{searchType === "Discover" && (
<>
<SearchItemWrapper
header="Request Movies"
items={jellyseerrMovieResults}
renderItem={(item: MovieResult) => (
<JellyseerrPoster item={item} key={item.id}/>
)}
/>
<SearchItemWrapper
header="Request Series"
items={jellyseerrTvResults}
renderItem={(item: TvResult) => (
<JellyseerrPoster item={item} key={item.id}/>
)}
/>
</>
)}
{loading ? (
<View className="mt-4 flex justify-center items-center">
<Loader />
@@ -435,7 +468,7 @@ export default function search() {
"{debouncedSearch}"
</Text>
</View>
) : debouncedSearch.length === 0 ? (
) : debouncedSearch.length === 0 && searchType === 'Library' ? (
<View className="mt-4 flex flex-col items-center space-y-2">
{exampleSearches.map((e) => (
<TouchableOpacity
@@ -447,6 +480,12 @@ export default function search() {
</TouchableOpacity>
))}
</View>
) : debouncedSearch.length === 0 && searchType === 'Discover' ? (
<View className="mt-4 flex flex-col space-y-2 px-2">
{sortBy?.(jellyseerrDiscoverSettings?.filter(s => s.enabled), 'order')
.map((slide) => <DiscoverSlide key={slide.id} slide={slide}/>)
}
</View>
) : null}
</View>
</ScrollView>

View File

@@ -1,2 +1,3 @@
export * from "./mmkv";
export * from "./number";
export * from "./mmkv";
export * from "./string";

View File

@@ -1,6 +1,9 @@
declare global {
interface Number {
bytesToReadable(): string;
secondsToMilliseconds(): number
minutesToMilliseconds(): number
hoursToMilliseconds(): number
}
}
@@ -19,4 +22,16 @@ Number.prototype.bytesToReadable = function () {
return `${bytes.toFixed(2)} B`;
}
Number.prototype.secondsToMilliseconds = function () {
return this.valueOf() * 1000
}
Number.prototype.minutesToMilliseconds = function () {
return this.valueOf() * (60).secondsToMilliseconds()
}
Number.prototype.hoursToMilliseconds = function () {
return this.valueOf() * (60).minutesToMilliseconds()
}
export {};

16
augmentations/string.ts Normal file
View File

@@ -0,0 +1,16 @@
declare global {
interface String {
toTitle(): string;
}
}
String.prototype.toTitle = function () {
return this
.replaceAll("_", " ")
.replace(
/\w\S*/g,
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
);
}
export {};

View File

@@ -8,14 +8,26 @@ interface TagProps {
textClass?: ViewProps["className"]
}
export const Tag: React.FC<{ text: string, textClass?: ViewProps["className"]} & ViewProps> = ({
text,
textClass,
...props
}) => {
return (
<View className="bg-neutral-800 rounded-full px-2 py-1" {...props}>
<Text className={textClass}>{text}</Text>
</View>
);
};
export const Tags: React.FC<TagProps & ViewProps> = ({ tags, textClass = "text-xs", ...props }) => {
if (!tags || tags.length === 0) return null;
return (
<View className={`flex flex-row flex-wrap gap-1 ${props.className}`} {...props}>
{tags.map((genre, idx) => (
<View key={idx} className="bg-neutral-800 rounded-full px-2 py-1">
<Text className={textClass}>{genre}</Text>
{tags.map((tag, idx) => (
<View>
<Tag key={idx} textClass={textClass} text={tag}/>
</View>
))}
</View>

View File

@@ -55,7 +55,9 @@ export const JellyserrRatings: React.FC<{result: MovieResult | TvResult}> = ({ r
? jellyseerrApi?.movieRatings(result.id)
: jellyseerrApi?.tvRatings(result.id)
},
enabled: !!jellyseerrApi
staleTime: (5).minutesToMilliseconds(),
retry: false,
enabled: !!jellyseerrApi,
});
return (isLoading || !!result.voteCount ||

View File

@@ -0,0 +1,75 @@
import React, {useMemo} from "react";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
import {DiscoverSliderType} from "@/utils/jellyseerr/server/constants/discover";
import {DiscoverEndpoint, Endpoints, useJellyseerr} from "@/hooks/useJellyseerr";
import {useInfiniteQuery} from "@tanstack/react-query";
import {MovieResult, TvResult} from "@/utils/jellyseerr/server/models/Search";
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
import {Text} from "@/components/common/Text";
import {FlashList} from "@shopify/flash-list";
interface Props {
slide: DiscoverSlider
}
const DiscoverSlide: React.FC<Props> = ({slide}) => {
const {jellyseerrApi} = useJellyseerr();
const {data, isFetching, fetchNextPage, hasNextPage} = useInfiniteQuery({
queryKey: ["jellyseerr", "discover", slide.id],
queryFn: async ({ pageParam }) => {
let endpoint: DiscoverEndpoint | undefined = 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 ? jellyseerrApi?.discover(endpoint, params) : null;
},
initialPageParam: 1,
getNextPageParam: (lastPage, pages) => ((lastPage?.page || pages?.findLast(p => p?.results.length)?.page) || 1) + 1,
enabled: !!jellyseerrApi,
staleTime: 0
});
const flatData = useMemo(() => data?.pages?.filter(p => p?.results.length).flatMap(p => p?.results), [data])
return (
(flatData && flatData?.length > 0) && <>
<Text className="font-bold text-lg mb-2">{DiscoverSliderType[slide.type].toString().toTitle()}</Text>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
keyExtractor={item => item!!.id.toString()}
estimatedItemSize={250}
data={flatData}
onEndReachedThreshold={1}
onEndReached={() => {
if (hasNextPage)
fetchNextPage()
}}
renderItem={({item}) =>
(item ? <JellyseerrPoster item={item as MovieResult | TvResult} /> : <></>)
}
/>
</>
)
}
export default DiscoverSlide;

View File

@@ -18,6 +18,7 @@ import {IssueStatus, IssueType} from "@/utils/jellyseerr/server/constants/issue"
import Issue from "@/utils/jellyseerr/server/entity/Issue";
import {RTRating} from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
import {writeErrorLog} from "@/utils/log";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
interface SearchParams {
query: string,
@@ -27,8 +28,8 @@ interface SearchParams {
interface SearchResults {
page: number,
total_pages: number,
total_results: number;
totalPages: number,
totalResults: number;
results: Results[];
}
@@ -40,7 +41,7 @@ export const clearJellyseerrStorageData = () => {
storage.delete(JELLYSEERR_COOKIES);
}
enum Endpoints {
export enum Endpoints {
STATUS = "/status",
API_V1 = "/api/v1",
SEARCH = "/search",
@@ -49,9 +50,16 @@ enum Endpoints {
RATINGS = "/ratings",
ISSUE = "/issue",
TV = "/tv",
SETTINGS = "/settings",
DISCOVER = "/discover",
DISCOVER_TRENDING = DISCOVER + "/trending",
DISCOVER_MOVIES = DISCOVER + "/movies",
DISCOVER_TV = DISCOVER + TV,
AUTH_JELLYFIN = "/auth/jellyfin",
}
export type DiscoverEndpoint = Endpoints.DISCOVER_TRENDING | Endpoints.DISCOVER_MOVIES | Endpoints.DISCOVER_TV;
export type TestResult = {
isValid: true;
requiresPass: boolean;
@@ -115,7 +123,9 @@ export class JellyseerrApi {
};
})
.catch((e) => {
console.error("Failed to test jellyseerr server url", e)
const msg = "Failed to test jellyseerr server url";
toast.error(msg)
console.error(msg, e)
return {
isValid: false,
requiresPass: false
@@ -137,6 +147,16 @@ export class JellyseerrApi {
})
}
async discoverSettings(): Promise<DiscoverSlider[]> {
return this.axios?.get<DiscoverSlider[]>(Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER)
.then(({data}) => data)
}
async discover(endpoint: DiscoverEndpoint, params: any): Promise<SearchResults> {
return this.axios?.get<SearchResults>(Endpoints.API_V1 + endpoint, { params })
.then(({data}) => data)
}
async search(params: SearchParams): Promise<SearchResults> {
const response = await this.axios?.get<SearchResults>(Endpoints.API_V1 + Endpoints.SEARCH, {params})
return response?.data
@@ -204,15 +224,19 @@ export class JellyseerrApi {
return response;
},
(error: AxiosError) => {
const errorMsg = "Jellyseerr response error:";
const errorMsg = "Jellyseerr response error";
console.error(errorMsg, error, error.response?.data);
writeErrorLog(
errorMsg + ` ${error.toString()}\n` +
errorMsg + `\n` +
`error: ${error.toString()}\n` +
`url: ${error?.config?.url}\n` +
`data:\n` +
JSON.stringify(error.response?.data)
);
if (error.status === 403) {
clearJellyseerrStorageData()
}
return Promise.reject(error)
}
);