import type { User as JellyseerrUser } from "@/utils/jellyseerr/server/entity/User"; import type { MovieResult, Results, TvResult, } from "@/utils/jellyseerr/server/models/Search"; import { storage } from "@/utils/mmkv"; import axios, { type AxiosError, type AxiosInstance } from "axios"; import { atom } from "jotai"; import { useAtom } from "jotai/index"; import { inRange } from "lodash"; import "@/augmentations"; import { useSettings } from "@/utils/atoms/settings"; import type { RTRating } from "@/utils/jellyseerr/server/api/rating/rottentomatoes"; import { IssueStatus, type IssueType, } from "@/utils/jellyseerr/server/constants/issue"; import { MediaRequestStatus, MediaType, } from "@/utils/jellyseerr/server/constants/media"; import type DiscoverSlider from "@/utils/jellyseerr/server/entity/DiscoverSlider"; import type Issue from "@/utils/jellyseerr/server/entity/Issue"; import type MediaRequest from "@/utils/jellyseerr/server/entity/MediaRequest"; import type { GenreSliderItem } from "@/utils/jellyseerr/server/interfaces/api/discoverInterfaces"; import type { MediaRequestBody, RequestResultsResponse, } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces"; import type { ServiceCommonServer, ServiceCommonServerWithDetails, } from "@/utils/jellyseerr/server/interfaces/api/serviceInterfaces"; import type { UserResultsResponse } from "@/utils/jellyseerr/server/interfaces/api/userInterfaces"; import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie"; import type { CombinedCredit, PersonDetails, } from "@/utils/jellyseerr/server/models/Person"; import type { SeasonWithEpisodes, TvDetails, } from "@/utils/jellyseerr/server/models/Tv"; import { writeErrorLog } from "@/utils/log"; import { useQueryClient } from "@tanstack/react-query"; import { t } from "i18next"; import { useCallback, useMemo } from "react"; import { toast } from "sonner-native"; interface SearchParams { query: string; page: number; // language: string; } interface SearchResults { page: number; totalPages: number; totalResults: number; results: Results[]; } const JELLYSEERR_USER = "JELLYSEERR_USER"; const JELLYSEERR_COOKIES = "JELLYSEERR_COOKIES"; export const clearJellyseerrStorageData = () => { storage.delete(JELLYSEERR_USER); storage.delete(JELLYSEERR_COOKIES); }; export enum Endpoints { STATUS = "/status", API_V1 = "/api/v1", SEARCH = "/search", REQUEST = "/request", PERSON = "/person", COMBINED_CREDITS = "/combined_credits", MOVIE = "/movie", RATINGS = "/ratings", ISSUE = "/issue", USER = "/user", SERVICE = "/service", TV = "/tv", SETTINGS = "/settings", NETWORK = "/network", STUDIO = "/studio", GENRE_SLIDER = "/genreslider", DISCOVER = "/discover", DISCOVER_TRENDING = `${DISCOVER}/trending`, DISCOVER_MOVIES = `${DISCOVER}/movies`, DISCOVER_TV = DISCOVER + TV, DISCOVER_TV_NETWORK = DISCOVER + TV + NETWORK, DISCOVER_MOVIES_STUDIO = `${DISCOVER}${MOVIE}s${STUDIO}`, AUTH_JELLYFIN = "/auth/jellyfin", } export type DiscoverEndpoint = | Endpoints.DISCOVER_TV_NETWORK | Endpoints.DISCOVER_TRENDING | Endpoints.DISCOVER_MOVIES | Endpoints.DISCOVER_TV; export type TestResult = | { isValid: true; requiresPass: boolean; } | { isValid: false; }; export class JellyseerrApi { axios: AxiosInstance; constructor(baseUrl: string) { this.axios = axios.create({ baseURL: baseUrl, withCredentials: true, withXSRFToken: true, xsrfHeaderName: "XSRF-TOKEN", }); this.setInterceptors(); } async test(): Promise { const user = storage.get(JELLYSEERR_USER); const cookies = storage.get(JELLYSEERR_COOKIES); if (user && cookies) { return Promise.resolve({ isValid: true, requiresPass: false, }); } return await this.axios .get(Endpoints.API_V1 + Endpoints.STATUS) .then((response) => { const { status, headers, data } = response; if (inRange(status, 200, 299)) { if (data.version < "2.0.0") { const error = t( "jellyseerr.toasts.jellyseer_does_not_meet_requirements", ); toast.error(error); throw Error(error); } storage.setAny( JELLYSEERR_COOKIES, headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? [], ); return { isValid: true, requiresPass: true, }; } toast.error(t("jellyseerr.toasts.jellyseerr_test_failed")); writeErrorLog( `Jellyseerr returned a ${status} for url:\n${response.config.url}`, response.data, ); return { isValid: false, requiresPass: false, }; }) .catch((e) => { const msg = t("jellyseerr.toasts.failed_to_test_jellyseerr_server_url"); toast.error(msg); console.error(msg, e); return { isValid: false, requiresPass: false, }; }); } async login(username: string, password: string): Promise { return this.axios ?.post(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, { username, password, email: username, }) .then((response) => { const user = response?.data; if (!user) throw Error("Login failed"); storage.setAny(JELLYSEERR_USER, user); return user; }); } async discoverSettings(): Promise { return this.axios ?.get( Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER, ) .then(({ data }) => data); } async discover( endpoint: DiscoverEndpoint | string, params: any, ): Promise { return this.axios ?.get(Endpoints.API_V1 + endpoint, { params }) .then(({ data }) => data); } async getGenreSliders( endpoint: Endpoints.TV | Endpoints.MOVIE, params: any = undefined, ): Promise { return this.axios ?.get( Endpoints.API_V1 + Endpoints.DISCOVER + Endpoints.GENRE_SLIDER + endpoint, { params }, ) .then(({ data }) => data); } async search(params: SearchParams): Promise { return this.axios ?.get(Endpoints.API_V1 + Endpoints.SEARCH, { params }) .then(({ data }) => data); } async request(request: MediaRequestBody): Promise { return this.axios ?.post(Endpoints.API_V1 + Endpoints.REQUEST, request) .then(({ data }) => data); } async getRequest(id: number): Promise { return this.axios ?.get(`${Endpoints.API_V1 + Endpoints.REQUEST}/${id}`) .then(({ data }) => data); } async requests( params = { filter: "all", take: 10, sort: "modified", skip: 0, }, ): Promise { return this.axios ?.get(Endpoints.API_V1 + Endpoints.REQUEST, { params, }) .then(({ data }) => data); } async movieDetails(id: number) { return this.axios ?.get(`${Endpoints.API_V1 + Endpoints.MOVIE}/${id}`) .then((response) => { return response?.data; }); } async personDetails(id: number | string): Promise { return this.axios ?.get(`${Endpoints.API_V1 + Endpoints.PERSON}/${id}`) .then((response) => { return response?.data; }); } async personCombinedCredits(id: number | string): Promise { return this.axios ?.get( `${ Endpoints.API_V1 + Endpoints.PERSON }/${id}${Endpoints.COMBINED_CREDITS}`, ) .then((response) => { return response?.data; }); } async movieRatings(id: number) { return this.axios ?.get( `${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}`, ) .then(({ data }) => data); } async tvDetails(id: number) { return this.axios ?.get(`${Endpoints.API_V1}${Endpoints.TV}/${id}`) .then((response) => { return response?.data; }); } async tvRatings(id: number) { return this.axios ?.get( `${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}`, ) .then(({ data }) => data); } async tvSeason(id: number, seasonId: number) { return this.axios ?.get( `${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}`, ) .then((response) => { return response?.data; }); } async user(params: any) { return this.axios ?.get(`${Endpoints.API_V1}${Endpoints.USER}`, { params, }) .then(({ data }) => data.results); } imageProxy(path?: string, filter = "original", width = 1920, quality = 75) { return path ? `${this.axios.defaults.baseURL}/_next/image?${new URLSearchParams( `url=https://image.tmdb.org/t/p/${filter}/${path}&w=${width}&q=${quality}`, ).toString()}` : `${this.axios?.defaults.baseURL}/images/overseerr_poster_not_found_logo_top.png`; } async submitIssue(mediaId: number, issueType: IssueType, message: string) { return this.axios ?.post(Endpoints.API_V1 + Endpoints.ISSUE, { mediaId, issueType, message, }) .then((response) => { const issue = response.data; if (issue.status === IssueStatus.OPEN) { toast.success(t("jellyseerr.toasts.issue_submitted")); } return issue; }); } async service(type: "radarr" | "sonarr") { return this.axios ?.get( `${Endpoints.API_V1 + Endpoints.SERVICE}/${type}`, ) .then(({ data }) => data); } async serviceDetails(type: "radarr" | "sonarr", id: number) { return this.axios ?.get( `${Endpoints.API_V1 + Endpoints.SERVICE}/${type}/${id}`, ) .then(({ data }) => data); } private setInterceptors() { this.axios.interceptors.response.use( async (response) => { const cookies = response.headers["set-cookie"]; if (cookies) { storage.setAny( JELLYSEERR_COOKIES, response.headers["set-cookie"]?.flatMap((c) => c.split("; ")), ); } return response; }, (error: AxiosError) => { writeErrorLog( `Jellyseerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`, error.response?.data, ); if (error.status === 403) { clearJellyseerrStorageData(); } return Promise.reject(error); }, ); this.axios.interceptors.request.use( async (config) => { const cookies = storage.get(JELLYSEERR_COOKIES); if (cookies) { const headerName = this.axios.defaults.xsrfHeaderName!; const xsrfToken = cookies .find((c) => c.includes(headerName)) ?.split(`${headerName}=`)?.[1]; if (xsrfToken) { config.headers[headerName] = xsrfToken; } } return config; }, (error) => { console.error("Jellyseerr request error", error); }, ); } } const jellyseerrUserAtom = atom(storage.get(JELLYSEERR_USER)); export const useJellyseerr = () => { const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom); const [settings, updateSettings] = useSettings(); const queryClient = useQueryClient(); const jellyseerrApi = useMemo(() => { const cookies = storage.get(JELLYSEERR_COOKIES); if (settings?.jellyseerrServerUrl && cookies && jellyseerrUser) { return new JellyseerrApi(settings?.jellyseerrServerUrl); } return undefined; }, [settings?.jellyseerrServerUrl, jellyseerrUser]); const clearAllJellyseerData = useCallback(async () => { clearJellyseerrStorageData(); setJellyseerrUser(undefined); updateSettings({ jellyseerrServerUrl: undefined }); }, []); const requestMedia = useCallback( (title: string, request: MediaRequestBody, onSuccess?: () => void) => { jellyseerrApi?.request?.(request)?.then(async (mediaRequest) => { await queryClient.invalidateQueries({ queryKey: ["search", "jellyseerr"], }); switch (mediaRequest.status) { case MediaRequestStatus.PENDING: case MediaRequestStatus.APPROVED: toast.success( t("jellyseerr.toasts.requested_item", { item: title }), ); onSuccess?.(); break; case MediaRequestStatus.DECLINED: toast.error( t("jellyseerr.toasts.you_dont_have_permission_to_request"), ); break; case MediaRequestStatus.FAILED: toast.error( t("jellyseerr.toasts.something_went_wrong_requesting_media"), ); break; } }); }, [jellyseerrApi], ); const isJellyseerrResult = ( items: any | null | undefined, ): items is Results => { return ( items && Object.hasOwn(items, "mediaType") && Object.values(MediaType).includes(items.mediaType) ); }; const getTitle = ( item?: TvResult | TvDetails | MovieResult | MovieDetails, ) => { return isJellyseerrResult(item) ? item.mediaType === MediaType.MOVIE ? item?.title : item?.name : item?.mediaInfo.mediaType === MediaType.MOVIE ? (item as MovieDetails)?.title : (item as TvDetails)?.name; }; const getYear = ( item?: TvResult | TvDetails | MovieResult | MovieDetails, ) => { return new Date( (isJellyseerrResult(item) ? item.mediaType === MediaType.MOVIE ? item?.releaseDate : item?.firstAirDate : item?.mediaInfo.mediaType === MediaType.MOVIE ? (item as MovieDetails)?.releaseDate : (item as TvDetails)?.firstAirDate) || "", )?.getFullYear?.(); }; const getMediaType = ( item?: TvResult | TvDetails | MovieResult | MovieDetails, ): MediaType => { return isJellyseerrResult(item) ? item.mediaType : item?.mediaInfo?.mediaType; }; const jellyseerrRegion = useMemo( () => jellyseerrUser?.settings?.region || "US", [jellyseerrUser], ); const jellyseerrLocale = useMemo(() => { return jellyseerrUser?.settings?.locale || "en"; }, [jellyseerrUser]); return { jellyseerrApi, jellyseerrUser, setJellyseerrUser, clearAllJellyseerData, isJellyseerrResult, getTitle, getYear, getMediaType, jellyseerrRegion, jellyseerrLocale, requestMedia, }; };