This commit is contained in:
Fredrik Burmester
2024-12-31 13:32:51 +01:00
parent 48422fa93e
commit c753e33f38
13 changed files with 403 additions and 317 deletions

View File

@@ -141,8 +141,6 @@ const Page = () => {
}): Promise<BaseItemDtoQueryResult | null> => { }): Promise<BaseItemDtoQueryResult | null> => {
if (!api || !library) return null; if (!api || !library) return null;
console.log("[libraryId] ~", library);
let itemType: BaseItemKind | undefined; let itemType: BaseItemKind | undefined;
// This fix makes sure to only return 1 type of items, if defined. // This fix makes sure to only return 1 type of items, if defined.

View File

@@ -282,13 +282,6 @@ export default function page() {
if (!item?.Id || !stream) return; if (!item?.Id || !stream) return;
console.log(
"onProgress ~",
currentTimeInTicks,
isPlaying,
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
);
await getPlaystateApi(api!).onPlaybackProgress({ await getPlaystateApi(api!).onPlaybackProgress({
itemId: item.Id, itemId: item.Id,
audioStreamIndex: audioIndex ? audioIndex : undefined, audioStreamIndex: audioIndex ? audioIndex : undefined,

View File

@@ -68,7 +68,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
// Needs to automatically change the selected to the default values for default indexes. // Needs to automatically change the selected to the default values for default indexes.
useEffect(() => { useEffect(() => {
console.log(defaultAudioIndex, defaultSubtitleIndex);
setSelectedOptions(() => ({ setSelectedOptions(() => ({
bitrate: defaultBitrate, bitrate: defaultBitrate,
mediaSource: defaultMediaSource, mediaSource: defaultMediaSource,
@@ -220,7 +219,6 @@ export const ItemContent: React.FC<{ item: BaseItemDto }> = React.memo(
className="mr-1" className="mr-1"
source={selectedOptions.mediaSource} source={selectedOptions.mediaSource}
onChange={(val) => { onChange={(val) => {
console.log(val);
setSelectedOptions( setSelectedOptions(
(prev) => (prev) =>
prev && { prev && {

View File

@@ -1,33 +1,37 @@
import {Text} from "@/components/common/Text"; import { Text } from "@/components/common/Text";
import React, {useCallback, useMemo, useState} from "react"; import React, { useCallback, useMemo, useState } from "react";
import {Alert, TouchableOpacity, View} from "react-native"; import { Alert, TouchableOpacity, View } from "react-native";
import {TvDetails} from "@/utils/jellyseerr/server/models/Tv"; import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
import {FlashList} from "@shopify/flash-list"; import { FlashList } from "@shopify/flash-list";
import {orderBy} from "lodash"; import { orderBy } from "lodash";
import {Tags} from "@/components/GenreTags"; import { Tags } from "@/components/GenreTags";
import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus"; import JellyseerrIconStatus from "@/components/icons/JellyseerrIconStatus";
import Season from "@/utils/jellyseerr/server/entity/Season"; import Season from "@/utils/jellyseerr/server/entity/Season";
import {MediaStatus, MediaType} from "@/utils/jellyseerr/server/constants/media"; import {
import {Ionicons} from "@expo/vector-icons"; MediaStatus,
import {RoundButton} from "@/components/RoundButton"; MediaType,
import {useJellyseerr} from "@/hooks/useJellyseerr"; } from "@/utils/jellyseerr/server/constants/media";
import {TvResult} from "@/utils/jellyseerr/server/models/Search"; import { Ionicons } from "@expo/vector-icons";
import {useQuery} from "@tanstack/react-query"; import { RoundButton } from "@/components/RoundButton";
import {HorizontalScroll} from "@/components/common/HorrizontalScroll"; import { useJellyseerr } from "@/hooks/useJellyseerr";
import {Image} from "expo-image"; import { TvResult } from "@/utils/jellyseerr/server/models/Search";
import { useQuery } from "@tanstack/react-query";
import { HorizontalScroll } from "@/components/common/HorrizontalScroll";
import { Image } from "expo-image";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import { Loader } from "../Loader";
const JellyseerrSeasonEpisodes: React.FC<{details: TvDetails, seasonNumber: number}> = ({ const JellyseerrSeasonEpisodes: React.FC<{
details, details: TvDetails;
seasonNumber seasonNumber: number;
}) => { }> = ({ details, seasonNumber }) => {
const {jellyseerrApi} = useJellyseerr(); const { jellyseerrApi } = useJellyseerr();
const {data: seasonWithEpisodes, isLoading} = useQuery({ const { data: seasonWithEpisodes, isLoading } = useQuery({
queryKey: ["jellyseerr", details.id, "season", seasonNumber], queryKey: ["jellyseerr", details.id, "season", seasonNumber],
queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber), queryFn: async () => jellyseerrApi?.tvSeason(details.id, seasonNumber),
enabled: details.seasons.filter(s => s.seasonNumber !== 0).length > 0 enabled: details.seasons.filter((s) => s.seasonNumber !== 0).length > 0,
}) });
return ( return (
<HorizontalScroll <HorizontalScroll
@@ -37,86 +41,102 @@ const JellyseerrSeasonEpisodes: React.FC<{details: TvDetails, seasonNumber: numb
estimatedItemSize={50} estimatedItemSize={50}
data={seasonWithEpisodes?.episodes} data={seasonWithEpisodes?.episodes}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => <View className="w-2"/>}
renderItem={(item, index) => ( renderItem={(item, index) => (
<View className="flex flex-col mt-2 w-44"> <RenderItem key={index} item={item} index={index} />
{item.stillPath && (
<View
className="relative aspect-video rounded-lg overflow-hidden border border-neutral-800"
>
<Image
key={item.id}
id={item.id}
source={{
uri: jellyseerrApi?.tvStillImageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
/>
</View>
)}
<View className="shrink">
<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 RenderItem = ({ item, index }: any) => {
const { jellyseerrApi } = useJellyseerr();
const [imageError, setImageError] = useState(false);
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: jellyseerrApi?.tvStillImageProxy(item.stillPath),
}}
cachePolicy={"memory-disk"}
contentFit="cover"
className="w-full h-full"
onError={(e) => {
setImageError(true);
}}
/>
) : (
<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 JellyseerrSeasons: React.FC<{ const JellyseerrSeasons: React.FC<{
isLoading: boolean, isLoading: boolean;
result?: TvResult, result?: TvResult;
details?: TvDetails details?: TvDetails;
}> = ({ }> = ({ isLoading, result, details }) => {
isLoading, if (!details) return null;
result,
details,
}) => {
if (!details)
return null;
const {jellyseerrApi, requestMedia} = useJellyseerr(); const { jellyseerrApi, requestMedia } = useJellyseerr();
const [seasonStates, setSeasonStates] = useState<{[key: number]: boolean}>(); const [seasonStates, setSeasonStates] = useState<{
[key: number]: boolean;
}>();
const seasons = useMemo(() => { const seasons = useMemo(() => {
const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter((s: Season) => s.seasonNumber !== 0) const mediaInfoSeasons = details?.mediaInfo?.seasons?.filter(
const requestedSeasons = details?.mediaInfo?.requests?.flatMap((r: MediaRequest) => r.seasons) (s: Season) => s.seasonNumber !== 0
return details.seasons?.map((season) => { );
return { const requestedSeasons = details?.mediaInfo?.requests?.flatMap(
...season, (r: MediaRequest) => r.seasons
status: );
// What our library status is return details.seasons?.map((season) => {
mediaInfoSeasons return {
?.find((mediaSeason: Season) => mediaSeason.seasonNumber === season.seasonNumber) ...season,
?.status status:
?? // What our library status is
// What our request status is mediaInfoSeasons?.find(
requestedSeasons (mediaSeason: Season) =>
?.find((s: Season) => s.seasonNumber === season.seasonNumber) mediaSeason.seasonNumber === season.seasonNumber
?.status )?.status ??
?? // What our request status is
// Otherwise set it as unknown requestedSeasons?.find(
MediaStatus.UNKNOWN (s: Season) => s.seasonNumber === season.seasonNumber
} )?.status ??
}) // Otherwise set it as unknown
}, MediaStatus.UNKNOWN,
[details] };
); });
}, [details]);
const allSeasonsAvailable = useMemo(() => const allSeasonsAvailable = useMemo(
seasons?.every(season => season.status === MediaStatus.AVAILABLE), () => seasons?.every((season) => season.status === MediaStatus.AVAILABLE),
[seasons] [seasons]
) );
const requestAll = useCallback(() => { const requestAll = useCallback(() => {
if (details && jellyseerrApi) { if (details && jellyseerrApi) {
@@ -125,48 +145,77 @@ const JellyseerrSeasons: React.FC<{
mediaType: MediaType.TV, mediaType: MediaType.TV,
tvdbId: details.externalIds?.tvdbId, tvdbId: details.externalIds?.tvdbId,
seasons: seasons seasons: seasons
.filter(s => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0) .filter(
.map(s => s.seasonNumber) (s) => s.status === MediaStatus.UNKNOWN && s.seasonNumber !== 0
}) )
.map((s) => s.seasonNumber),
});
} }
}, [jellyseerrApi, seasons, details]) }, [jellyseerrApi, seasons, details]);
const promptRequestAll = useCallback(() => ( const promptRequestAll = useCallback(
Alert.alert('Request all?', 'Are you sure you want to request all seasons?', [ () =>
{ Alert.alert(
text: 'Cancel', "Request all?",
style: 'cancel', "Are you sure you want to request all seasons?",
}, [
{ {
text: 'YES', text: "Cancel",
onPress: requestAll style: "cancel",
}, },
])), [requestAll]); {
text: "YES",
onPress: requestAll,
},
]
),
[requestAll]
);
if (isLoading)
return (
<View>
<View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">Seasons</Text>
{!allSeasonsAvailable && (
<RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
<Ionicons name="bag-add" color="white" size={26} />
</RoundButton>
)}
</View>
<Loader />
</View>
);
return ( return (
<FlashList <FlashList
data={orderBy(details.seasons.filter(s => s.seasonNumber !== 0), 'seasonNumber', 'desc')} data={orderBy(
details.seasons.filter((s) => s.seasonNumber !== 0),
"seasonNumber",
"desc"
)}
ListHeaderComponent={() => ( ListHeaderComponent={() => (
<View className="flex flex-row justify-between items-end"> <View className="flex flex-row justify-between items-end px-4">
<Text className="text-lg font-bold mb-2">Seasons</Text> <Text className="text-lg font-bold mb-2">Seasons</Text>
{!allSeasonsAvailable && ( {!allSeasonsAvailable && (
<RoundButton <RoundButton className="mb-2 pa-2" onPress={promptRequestAll}>
className="mb-2 pa-2" <Ionicons name="bag-add" color="white" size={26} />
onPress={promptRequestAll}
>
<Ionicons name="bag-add" color="white" size={26}/>
</RoundButton> </RoundButton>
)} )}
</View> </View>
)} )}
ItemSeparatorComponent={() => <View className="h-2" />} ItemSeparatorComponent={() => <View className="h-2" />}
estimatedItemSize={250} estimatedItemSize={250}
renderItem={({item: season}) => ( renderItem={({ item: season }) => (
<> <>
<TouchableOpacity <TouchableOpacity
onPress={() => setSeasonStates((prevState) => ( onPress={() =>
{...prevState, [season.seasonNumber]: !prevState?.[season.seasonNumber]} setSeasonStates((prevState) => ({
))} ...prevState,
[season.seasonNumber]: !prevState?.[season.seasonNumber],
}))
}
className="px-4"
> >
<View <View
className="flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4" className="flex flex-row justify-between items-center bg-gray-100/10 rounded-xl z-20 h-12 w-full px-4"
@@ -174,27 +223,43 @@ const JellyseerrSeasons: React.FC<{
> >
<Tags <Tags
textClass="" textClass=""
tags={[`Season ${season.seasonNumber}`, `${season.episodeCount} Episodes`]} tags={[
`Season ${season.seasonNumber}`,
`${season.episodeCount} Episodes`,
]}
/> />
{[0].map(() => { {[0].map(() => {
const canRequest = seasons?.find(s => s.seasonNumber === season.seasonNumber)?.status === MediaStatus.UNKNOWN const canRequest =
return <JellyseerrIconStatus seasons?.find((s) => s.seasonNumber === season.seasonNumber)
key={0} ?.status === MediaStatus.UNKNOWN;
onPress={canRequest ? () => return (
requestMedia( <JellyseerrIconStatus
`${result?.name!!}, Season ${season.seasonNumber}`, key={0}
{ onPress={
mediaId: details.id, canRequest
mediaType: MediaType.TV, ? () =>
tvdbId: details.externalIds?.tvdbId, requestMedia(
seasons: [season.seasonNumber] `${result?.name!!}, Season ${
} season.seasonNumber
) : undefined }`,
} {
className={canRequest ? 'bg-gray-700/40' : undefined} mediaId: details.id,
mediaStatus={seasons?.find(s => s.seasonNumber === season.seasonNumber)?.status} mediaType: MediaType.TV,
showRequestIcon={canRequest} tvdbId: details.externalIds?.tvdbId,
/> seasons: [season.seasonNumber],
}
)
: undefined
}
className={canRequest ? "bg-gray-700/40" : undefined}
mediaStatus={
seasons?.find(
(s) => s.seasonNumber === season.seasonNumber
)?.status
}
showRequestIcon={canRequest}
/>
);
})} })}
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@@ -206,10 +271,9 @@ const JellyseerrSeasons: React.FC<{
/> />
)} )}
</> </>
) )}
}
/> />
) );
} };
export default JellyseerrSeasons; export default JellyseerrSeasons;

View File

@@ -57,8 +57,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => {
updateSettings(update); updateSettings(update);
console.log("update", update);
let updatePayload = { let updatePayload = {
SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode, SubtitleMode: update?.subtitleMode ?? settings?.subtitleMode,
PlayDefaultAudioTrack: PlayDefaultAudioTrack:
@@ -84,8 +82,6 @@ export const MediaProvider = ({ children }: { children: ReactNode }) => {
settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName || settings?.defaultSubtitleLanguage?.ThreeLetterISOLanguageName ||
""; "";
console.log("updatePayload", updatePayload);
updateUserConfiguration(updatePayload); updateUserConfiguration(updatePayload);
}; };

View File

@@ -20,7 +20,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const fetchInitialVolume = async () => { const fetchInitialVolume = async () => {
try { try {
const { volume: initialVolume } = await VolumeManager.getVolume(); const { volume: initialVolume } = await VolumeManager.getVolume();
console.log("initialVolume", initialVolume);
volume.value = initialVolume * 100; volume.value = initialVolume * 100;
} catch (error) { } catch (error) {
console.error("Error fetching initial volume:", error); console.error("Error fetching initial volume:", error);
@@ -39,7 +38,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
const handleValueChange = async (value: number) => { const handleValueChange = async (value: number) => {
volume.value = value; volume.value = value;
console.log("volume through slider", value);
await VolumeManager.setVolume(value / 100); await VolumeManager.setVolume(value / 100);
// Re-call showNativeVolumeUI to ensure the setting is applied on iOS // Re-call showNativeVolumeUI to ensure the setting is applied on iOS
@@ -48,7 +46,6 @@ const AudioSlider: React.FC<AudioSliderProps> = ({ setVisibility }) => {
useEffect(() => { useEffect(() => {
const volumeListener = VolumeManager.addVolumeListener((result) => { const volumeListener = VolumeManager.addVolumeListener((result) => {
console.log("Volume through device", result.volume);
volume.value = result.volume * 100; volume.value = result.volume * 100;
setVisibility(true); setVisibility(true);

View File

@@ -14,7 +14,6 @@ const BrightnessSlider = () => {
useEffect(() => { useEffect(() => {
const fetchInitialBrightness = async () => { const fetchInitialBrightness = async () => {
const initialBrightness = await Brightness.getBrightnessAsync(); const initialBrightness = await Brightness.getBrightnessAsync();
console.log("initialBrightness", initialBrightness);
brightness.value = initialBrightness * 100; brightness.value = initialBrightness * 100;
}; };
fetchInitialBrightness(); fetchInitialBrightness();

View File

@@ -240,8 +240,6 @@ export const Controls: React.FC<Props> = ({
? maxValue - currentProgress ? maxValue - currentProgress
: ticksToSeconds(maxValue - currentProgress); : ticksToSeconds(maxValue - currentProgress);
console.log("remaining: ", remaining);
setCurrentTime(current); setCurrentTime(current);
setRemainingTime(remaining); setRemainingTime(remaining);
}, },
@@ -349,7 +347,6 @@ export const Controls: React.FC<Props> = ({
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
try { try {
const curr = progress.value; const curr = progress.value;
console.log(curr);
if (curr !== undefined) { if (curr !== undefined) {
const newTime = isVlc const newTime = isVlc
? curr + secondsToMs(settings.forwardSkipTime) ? curr + secondsToMs(settings.forwardSkipTime)
@@ -375,8 +372,6 @@ export const Controls: React.FC<Props> = ({
const tileWidth = 150; const tileWidth = 150;
const tileHeight = 150 / trickplayInfo.aspectRatio!; const tileHeight = 150 / trickplayInfo.aspectRatio!;
console.log("time, ", time);
return ( return (
<View <View
style={{ style={{

View File

@@ -35,7 +35,6 @@ const NextEpisodeCountDownButton: React.FC<NextEpisodeCountDownButtonProps> = ({
}, },
(finished) => { (finished) => {
if (finished && onFinish) { if (finished && onFinish) {
console.log("finish");
runOnJS(onFinish)(); runOnJS(onFinish)();
} }
} }

View File

@@ -106,19 +106,12 @@ const DropdownViewDirect: React.FC<DropdownViewDirectProps> = ({
if ("deliveryUrl" in sub && sub.deliveryUrl) { if ("deliveryUrl" in sub && sub.deliveryUrl) {
setSubtitleURL && setSubtitleURL &&
setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name); setSubtitleURL(api?.basePath + sub.deliveryUrl, sub.name);
console.log(
"Set external subtitle: ",
api?.basePath + sub.deliveryUrl
);
} else { } else {
console.log("Set sub index: ", sub.index);
setSubtitleTrack && setSubtitleTrack(sub.index); setSubtitleTrack && setSubtitleTrack(sub.index);
} }
router.setParams({ router.setParams({
subtitleIndex: sub.index.toString(), subtitleIndex: sub.index.toString(),
}); });
console.log("Subtitle: ", sub);
}} }}
> >
<DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}> <DropdownMenu.ItemTitle key={`subtitle-item-title-${idx}`}>

View File

@@ -66,7 +66,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles); const sortedSubtitles = subtitleHelper.getSortedSubtitles(textSubtitles);
console.log("sortedSubtitles", sortedSubtitles);
return [disableSubtitle, ...sortedSubtitles]; return [disableSubtitle, ...sortedSubtitles];
} }
@@ -104,7 +103,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
const ChangeTranscodingAudio = useCallback( const ChangeTranscodingAudio = useCallback(
(audioIndex: number) => { (audioIndex: number) => {
console.log("ChangeTranscodingAudio", subtitleIndex, audioIndex);
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
itemId: item.Id ?? "", // Ensure itemId is a string itemId: item.Id ?? "", // Ensure itemId is a string
audioIndex: audioIndex?.toString() ?? "", audioIndex: audioIndex?.toString() ?? "",
@@ -167,7 +165,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
} }
key={`subtitle-item-${idx}`} key={`subtitle-item-${idx}`}
onValueChange={() => { onValueChange={() => {
console.log("sub", sub);
if ( if (
subtitleIndex === subtitleIndex ===
(isOnTextSubtitle && sub.IsTextSubtitleStream (isOnTextSubtitle && sub.IsTextSubtitleStream
@@ -216,7 +213,6 @@ const DropdownView: React.FC<DropdownViewProps> = ({ showControls }) => {
value={audioIndex === track.index.toString()} value={audioIndex === track.index.toString()}
onValueChange={() => { onValueChange={() => {
if (audioIndex === track.index.toString()) return; if (audioIndex === track.index.toString()) return;
console.log("Setting audio track to: ", track.index);
router.setParams({ router.setParams({
audioIndex: track.index.toString(), audioIndex: track.index.toString(),
}); });

View File

@@ -76,7 +76,6 @@ export const useIntroSkipper = (
}, [introTimestamps, currentTime]); }, [introTimestamps, currentTime]);
const skipIntro = useCallback(() => { const skipIntro = useCallback(() => {
console.log("skipIntro");
if (!introTimestamps) return; if (!introTimestamps) return;
try { try {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);

View File

@@ -1,45 +1,54 @@
import axios, {AxiosError, AxiosInstance} from "axios"; import axios, { AxiosError, AxiosInstance } from "axios";
import {Results} from "@/utils/jellyseerr/server/models/Search"; import { Results } from "@/utils/jellyseerr/server/models/Search";
import { storage } from "@/utils/mmkv"; import { storage } from "@/utils/mmkv";
import {inRange} from "lodash"; import { inRange } from "lodash";
import {User as JellyseerrUser} from "@/utils/jellyseerr/server/entity/User"; import { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User";
import {atom} from "jotai"; import { atom } from "jotai";
import {useAtom} from "jotai/index"; import { useAtom } from "jotai/index";
import "@/augmentations"; import "@/augmentations";
import {useCallback, useMemo} from "react"; import { useCallback, useMemo } from "react";
import {useSettings} from "@/utils/atoms/settings"; import { useSettings } from "@/utils/atoms/settings";
import {toast} from "sonner-native"; import { toast } from "sonner-native";
import {MediaRequestStatus, MediaType} from "@/utils/jellyseerr/server/constants/media"; import {
MediaRequestStatus,
MediaType,
} from "@/utils/jellyseerr/server/constants/media";
import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest";
import {MediaRequestBody} from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
import {MovieDetails} from "@/utils/jellyseerr/server/models/Movie"; import { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
import {SeasonWithEpisodes, TvDetails} from "@/utils/jellyseerr/server/models/Tv"; import {
import {IssueStatus, IssueType} from "@/utils/jellyseerr/server/constants/issue"; SeasonWithEpisodes,
TvDetails,
} from "@/utils/jellyseerr/server/models/Tv";
import {
IssueStatus,
IssueType,
} from "@/utils/jellyseerr/server/constants/issue";
import Issue from "@/utils/jellyseerr/server/entity/Issue"; import Issue from "@/utils/jellyseerr/server/entity/Issue";
import {RTRating} from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; import { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes";
import {writeErrorLog} from "@/utils/log"; import { writeErrorLog } from "@/utils/log";
import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; import DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider";
interface SearchParams { interface SearchParams {
query: string, query: string;
page: number, page: number;
language: string; language: string;
} }
interface SearchResults { interface SearchResults {
page: number, page: number;
totalPages: number, totalPages: number;
totalResults: number; totalResults: number;
results: Results[]; results: Results[];
} }
const JELLYSEERR_USER = "JELLYSEERR_USER" const JELLYSEERR_USER = "JELLYSEERR_USER";
const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES" const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES";
export const clearJellyseerrStorageData = () => { export const clearJellyseerrStorageData = () => {
storage.delete(JELLYSEERR_USER); storage.delete(JELLYSEERR_USER);
storage.delete(JELLYSEERR_COOKIES); storage.delete(JELLYSEERR_COOKIES);
} };
export enum Endpoints { export enum Endpoints {
STATUS = "/status", STATUS = "/status",
@@ -58,24 +67,29 @@ export enum Endpoints {
AUTH_JELLYFIN = "/auth/jellyfin", AUTH_JELLYFIN = "/auth/jellyfin",
} }
export type DiscoverEndpoint = Endpoints.DISCOVER_TRENDING | Endpoints.DISCOVER_MOVIES | Endpoints.DISCOVER_TV; export type DiscoverEndpoint =
| Endpoints.DISCOVER_TRENDING
| Endpoints.DISCOVER_MOVIES
| Endpoints.DISCOVER_TV;
export type TestResult = { export type TestResult =
isValid: true; | {
requiresPass: boolean; isValid: true;
} | { requiresPass: boolean;
isValid: false; }
}; | {
isValid: false;
};
export class JellyseerrApi { export class JellyseerrApi {
axios: AxiosInstance; axios: AxiosInstance;
constructor (baseUrl: string) { constructor(baseUrl: string) {
this.axios = axios.create({ this.axios = axios.create({
baseURL: baseUrl, baseURL: baseUrl,
withCredentials: true, withCredentials: true,
withXSRFToken: true, withXSRFToken: true,
xsrfHeaderName: "XSRF-TOKEN" xsrfHeaderName: "XSRF-TOKEN",
}); });
this.setInterceptors(); this.setInterceptors();
@@ -86,132 +100,169 @@ export class JellyseerrApi {
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES); const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (user && cookies) { if (user && cookies) {
console.log("User & cookies data exist for jellyseerr")
return Promise.resolve({ return Promise.resolve({
isValid: true, isValid: true,
requiresPass: false requiresPass: false,
}); });
} }
console.log("Testing jellyseerr connection") return await this.axios
return await this.axios.get(Endpoints.API_V1 + Endpoints.STATUS) .get(Endpoints.API_V1 + Endpoints.STATUS)
.then((response) => { .then((response) => {
const {status, headers, data} = response; const { status, headers, data } = response;
if (inRange(status, 200, 299)) { if (inRange(status, 200, 299)) {
if (data.version < "2.0.0") { if (data.version < "2.0.0") {
const error = "Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0"; const error =
"Jellyseerr server does not meet minimum version requirements! Please update to at least 2.0.0";
toast.error(error); toast.error(error);
throw Error(error); throw Error(error);
} }
console.log("Jellyseerr connecting successfully tested!"); storage.setAny(
storage.setAny(JELLYSEERR_COOKIES, headers["set-cookie"]?.flatMap(c => c.split("; ")) ?? []); JELLYSEERR_COOKIES,
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? []
);
return { return {
isValid: true, isValid: true,
requiresPass: true requiresPass: true,
}; };
} }
toast.error(`Jellyseerr test failed. Please try again.`); toast.error(`Jellyseerr test failed. Please try again.`);
writeErrorLog( writeErrorLog(
`Jellyseerr returned a ${status} for url:\n` + `Jellyseerr returned a ${status} for url:\n` +
response.config.url + '\n' + response.config.url +
JSON.stringify(response.data) "\n" +
JSON.stringify(response.data)
); );
return { return {
isValid: false, isValid: false,
requiresPass: false requiresPass: false,
}; };
}) })
.catch((e) => { .catch((e) => {
const msg = "Failed to test jellyseerr server url"; const msg = "Failed to test jellyseerr server url";
toast.error(msg) toast.error(msg);
console.error(msg, e) console.error(msg, e);
return { return {
isValid: false, isValid: false,
requiresPass: false requiresPass: false,
}; };
}) });
} }
async login(username: string, password: string): Promise<JellyseerrUser> { async login(username: string, password: string): Promise<JellyseerrUser> {
return this.axios?.post<JellyseerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, { return this.axios
username, ?.post<JellyseerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
password, username,
email: username password,
}).then(response => { email: username,
const user = response?.data; })
if (!user) .then((response) => {
throw Error("Login failed") const user = response?.data;
storage.setAny(JELLYSEERR_USER, user); if (!user) throw Error("Login failed");
return user storage.setAny(JELLYSEERR_USER, user);
}) return user;
});
} }
async discoverSettings(): Promise<DiscoverSlider[]> { async discoverSettings(): Promise<DiscoverSlider[]> {
return this.axios?.get<DiscoverSlider[]>(Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER) return this.axios
.then(({data}) => data) ?.get<DiscoverSlider[]>(
Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER
)
.then(({ data }) => data);
} }
async discover(endpoint: DiscoverEndpoint, params: any): Promise<SearchResults> { async discover(
return this.axios?.get<SearchResults>(Endpoints.API_V1 + endpoint, { params }) endpoint: DiscoverEndpoint,
.then(({data}) => data) params: any
): Promise<SearchResults> {
return this.axios
?.get<SearchResults>(Endpoints.API_V1 + endpoint, { params })
.then(({ data }) => data);
} }
async search(params: SearchParams): Promise<SearchResults> { async search(params: SearchParams): Promise<SearchResults> {
const response = await this.axios?.get<SearchResults>(Endpoints.API_V1 + Endpoints.SEARCH, {params}) const response = await this.axios?.get<SearchResults>(
return response?.data Endpoints.API_V1 + Endpoints.SEARCH,
{ params }
);
return response?.data;
} }
async request(request: MediaRequestBody): Promise<MediaRequest> { async request(request: MediaRequestBody): Promise<MediaRequest> {
return this.axios?.post<MediaRequest>(Endpoints.API_V1 + Endpoints.REQUEST, request) return this.axios
.then(({data}) => data) ?.post<MediaRequest>(Endpoints.API_V1 + Endpoints.REQUEST, request)
.then(({ data }) => data);
} }
async movieDetails(id: number) { async movieDetails(id: number) {
return this.axios?.get<MovieDetails>(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`).then(response => { return this.axios
return response?.data ?.get<MovieDetails>(Endpoints.API_V1 + Endpoints.MOVIE + `/${id}`)
}) .then((response) => {
return response?.data;
});
} }
async movieRatings(id: number) { async movieRatings(id: number) {
return this.axios?.get<RTRating>(`${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}`) return this.axios
.then(({data}) => data) ?.get<RTRating>(
`${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}`
)
.then(({ data }) => data);
} }
async tvDetails(id: number) { async tvDetails(id: number) {
return this.axios?.get<TvDetails>(`${Endpoints.API_V1}${Endpoints.TV}/${id}`).then(response => { return this.axios
return response?.data ?.get<TvDetails>(`${Endpoints.API_V1}${Endpoints.TV}/${id}`)
}) .then((response) => {
return response?.data;
});
} }
async tvRatings(id: number) { async tvRatings(id: number) {
return this.axios?.get<RTRating>(`${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}`) return this.axios
.then(({data}) => data) ?.get<RTRating>(
`${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}`
)
.then(({ data }) => data);
} }
async tvSeason(id: number, seasonId: number) { async tvSeason(id: number, seasonId: number) {
return this.axios?.get<SeasonWithEpisodes>(`${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}`).then(response => { return this.axios
console.log(response.data.episodes) ?.get<SeasonWithEpisodes>(
return response?.data `${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}`
}) )
.then((response) => {
return response?.data;
});
} }
tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) { tvStillImageProxy(path: string, width: number = 1920, quality: number = 75) {
return this.axios.defaults.baseURL + `/_next/image?` + new URLSearchParams(`url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`).toString() return (
this.axios.defaults.baseURL +
`/_next/image?` +
new URLSearchParams(
`url=https://image.tmdb.org/t/p/original/${path}&w=${width}&q=${quality}`
).toString()
);
} }
async submitIssue(mediaId: number, issueType: IssueType, message: string) { async submitIssue(mediaId: number, issueType: IssueType, message: string) {
return this.axios?.post<Issue>(Endpoints.API_V1 + Endpoints.ISSUE, { return this.axios
mediaId, issueType, message ?.post<Issue>(Endpoints.API_V1 + Endpoints.ISSUE, {
}).then((response) => { mediaId,
const issue = response.data issueType,
message,
})
.then((response) => {
const issue = response.data;
if (issue.status === IssueStatus.OPEN) { if (issue.status === IssueStatus.OPEN) {
toast.success("Issue submitted!") toast.success("Issue submitted!");
} }
return issue return issue;
}) });
} }
private setInterceptors() { private setInterceptors() {
@@ -219,7 +270,10 @@ export class JellyseerrApi {
async (response) => { async (response) => {
const cookies = response.headers["set-cookie"]; const cookies = response.headers["set-cookie"];
if (cookies) { if (cookies) {
storage.setAny(JELLYSEERR_COOKIES, response.headers["set-cookie"]?.flatMap(c => c.split("; "))); storage.setAny(
JELLYSEERR_COOKIES,
response.headers["set-cookie"]?.flatMap((c) => c.split("; "))
);
} }
return response; return response;
}, },
@@ -227,16 +281,17 @@ export class JellyseerrApi {
const errorMsg = "Jellyseerr response error"; const errorMsg = "Jellyseerr response error";
console.error(errorMsg, error, error.response?.data); console.error(errorMsg, error, error.response?.data);
writeErrorLog( writeErrorLog(
errorMsg + `\n` + errorMsg +
`error: ${error.toString()}\n` + `\n` +
`url: ${error?.config?.url}\n` + `error: ${error.toString()}\n` +
`data:\n` + `url: ${error?.config?.url}\n` +
JSON.stringify(error.response?.data) `data:\n` +
JSON.stringify(error.response?.data)
); );
if (error.status === 403) { if (error.status === 403) {
clearJellyseerrStorageData() clearJellyseerrStorageData();
} }
return Promise.reject(error) return Promise.reject(error);
} }
); );
@@ -246,22 +301,22 @@ export class JellyseerrApi {
if (cookies) { if (cookies) {
const headerName = this.axios.defaults.xsrfHeaderName!!; const headerName = this.axios.defaults.xsrfHeaderName!!;
const xsrfToken = cookies const xsrfToken = cookies
.find(c => c.includes(headerName)) .find((c) => c.includes(headerName))
?.split(headerName + "=")?.[1] ?.split(headerName + "=")?.[1];
if (xsrfToken) { if (xsrfToken) {
config.headers[headerName] = xsrfToken; config.headers[headerName] = xsrfToken;
} }
} }
return config return config;
}, },
(error) => { (error) => {
console.error("Jellyseerr request error", error) console.error("Jellyseerr request error", error);
} }
); );
} }
} }
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER)) const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
export const useJellyseerr = () => { export const useJellyseerr = () => {
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom); const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
@@ -270,43 +325,47 @@ export const useJellyseerr = () => {
const jellyseerrApi = useMemo(() => { const jellyseerrApi = useMemo(() => {
const cookies = storage.get<string[]>(JELLYSEERR_COOKIES); const cookies = storage.get<string[]>(JELLYSEERR_COOKIES);
if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) { if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) {
return new JellyseerrApi(settings?.jellyseerrServerUrl) return new JellyseerrApi(settings?.jellyseerrServerUrl);
} }
return undefined return undefined;
}, [settings?.jellyseerrServerUrl, jellyseerrUser]) }, [settings?.jellyseerrServerUrl, jellyseerrUser]);
const clearAllJellyseerData = useCallback(async () => { const clearAllJellyseerData = useCallback(async () => {
clearJellyseerrStorageData() clearJellyseerrStorageData();
setJellyseerrUser(undefined); setJellyseerrUser(undefined);
updateSettings({jellyseerrServerUrl: undefined}) updateSettings({ jellyseerrServerUrl: undefined });
}, []); }, []);
const requestMedia = useCallback(( const requestMedia = useCallback(
title: string, (title: string, request: MediaRequestBody) => {
request: MediaRequestBody, jellyseerrApi?.request?.(request)?.then((mediaRequest) => {
) => { switch (mediaRequest.status) {
jellyseerrApi?.request?.(request)?.then((mediaRequest) => { case MediaRequestStatus.PENDING:
switch (mediaRequest.status) { case MediaRequestStatus.APPROVED:
case MediaRequestStatus.PENDING: toast.success(`Requested ${title}!`);
case MediaRequestStatus.APPROVED: break;
toast.success(`Requested ${title}!`) case MediaRequestStatus.DECLINED:
break; toast.error(`You don't have permission to request!`);
case MediaRequestStatus.DECLINED: break;
toast.error(`You don't have permission to request!`) case MediaRequestStatus.FAILED:
break; toast.error(`Something went wrong requesting media!`);
case MediaRequestStatus.FAILED: break;
toast.error(`Something went wrong requesting media!`) }
break; });
} },
}) [jellyseerrApi]
}, [jellyseerrApi]) );
const isJellyseerrResult = (items: any[] | null | undefined): items is Results[] => { const isJellyseerrResult = (
items: any[] | null | undefined
): items is Results[] => {
return ( return (
!items || !items ||
items.length >= 0 && Object.hasOwn(items[0], "mediaType") && Object.values(MediaType).includes(items[0]['mediaType']) (items.length >= 0 &&
) Object.hasOwn(items[0], "mediaType") &&
} Object.values(MediaType).includes(items[0]["mediaType"]))
);
};
return { return {
jellyseerrApi, jellyseerrApi,
@@ -314,6 +373,6 @@ export const useJellyseerr = () => {
setJellyseerrUser, setJellyseerrUser,
clearAllJellyseerData, clearAllJellyseerData,
isJellyseerrResult, isJellyseerrResult,
requestMedia requestMedia,
} };
}; };