Files
streamyfin/hooks/useJellyseerr.ts
Uruk b7db06f53d docs: update README and fix typos across codebase
Enhances README with comprehensive feature categorization including Media Playback, Media Management, and Advanced Features sections

Expands documentation for music library support, search providers (Marlin, Streamystats, Jellysearch), and plugin functionality

Updates FAQ section with more detailed answers about library visibility, downloads, subtitles, TV platform support, and Seerr integration

Corrects typos throughout the application:
- Fixes "liraries" to "libraries" in hide libraries settings
- Changes "centralised" to "centralized" for consistency
- Updates "Jellyseerr" references to "Seerr" throughout codebase

Adds missing translations for player settings including aspect ratio options, alignment controls, and MPV subtitle customization

Improves consistency in capitalization and punctuation across translation strings
2026-01-12 09:41:24 +01:00

554 lines
16 KiB
TypeScript

import axios, { type AxiosError, type AxiosInstance } from "axios";
import { atom } from "jotai";
import { useAtom } from "jotai/index";
import { inRange } from "lodash";
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 "@/augmentations";
import { t } from "i18next";
import { useCallback, useMemo } from "react";
import { toast } from "sonner-native";
import { useNetworkAwareQueryClient } from "@/hooks/useNetworkAwareQueryClient";
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,
PersonCreditCast,
PersonDetails,
} from "@/utils/jellyseerr/server/models/Person";
import type {
SeasonWithEpisodes,
TvDetails,
} from "@/utils/jellyseerr/server/models/Tv";
import { writeErrorLog } from "@/utils/log";
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.remove(JELLYSEERR_USER);
storage.remove(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<TestResult> {
const user = storage.get<JellyseerrUser>(JELLYSEERR_USER);
const cookies = storage.get<string[]>(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(
`Seerr 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<JellyseerrUser> {
return this.axios
?.post<JellyseerrUser>(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<DiscoverSlider[]> {
return this.axios
?.get<DiscoverSlider[]>(
Endpoints.API_V1 + Endpoints.SETTINGS + Endpoints.DISCOVER,
)
.then(({ data }) => data);
}
async discover(
endpoint: DiscoverEndpoint | string,
params: any,
): Promise<SearchResults> {
return this.axios
?.get<SearchResults>(Endpoints.API_V1 + endpoint, { params })
.then(({ data }) => data);
}
async getGenreSliders(
endpoint: Endpoints.TV | Endpoints.MOVIE,
params: any = undefined,
): Promise<GenreSliderItem[]> {
return this.axios
?.get<GenreSliderItem[]>(
Endpoints.API_V1 +
Endpoints.DISCOVER +
Endpoints.GENRE_SLIDER +
endpoint,
{ params },
)
.then(({ data }) => data);
}
async search(params: SearchParams): Promise<SearchResults> {
return this.axios
?.get<SearchResults>(Endpoints.API_V1 + Endpoints.SEARCH, { params })
.then(({ data }) => data);
}
async request(request: MediaRequestBody): Promise<MediaRequest> {
return this.axios
?.post<MediaRequest>(Endpoints.API_V1 + Endpoints.REQUEST, request)
.then(({ data }) => data);
}
async getRequest(id: number): Promise<MediaRequest> {
return this.axios
?.get<MediaRequest>(`${Endpoints.API_V1 + Endpoints.REQUEST}/${id}`)
.then(({ data }) => data);
}
async approveRequest(requestId: number): Promise<MediaRequest> {
return this.axios
?.post<MediaRequest>(
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/approve`,
)
.then(({ data }) => data);
}
async declineRequest(requestId: number): Promise<MediaRequest> {
return this.axios
?.post<MediaRequest>(
`${Endpoints.API_V1 + Endpoints.REQUEST}/${requestId}/decline`,
)
.then(({ data }) => data);
}
async requests(
params = {
filter: "all",
take: 10,
sort: "modified",
skip: 0,
},
): Promise<RequestResultsResponse> {
return this.axios
?.get<RequestResultsResponse>(Endpoints.API_V1 + Endpoints.REQUEST, {
params,
})
.then(({ data }) => data);
}
async movieDetails(id: number) {
return this.axios
?.get<MovieDetails>(`${Endpoints.API_V1 + Endpoints.MOVIE}/${id}`)
.then((response) => {
return response?.data;
});
}
async personDetails(id: number | string): Promise<PersonDetails> {
return this.axios
?.get<PersonDetails>(`${Endpoints.API_V1 + Endpoints.PERSON}/${id}`)
.then((response) => {
return response?.data;
});
}
async personCombinedCredits(id: number | string): Promise<CombinedCredit> {
return this.axios
?.get<CombinedCredit>(
`${
Endpoints.API_V1 + Endpoints.PERSON
}/${id}${Endpoints.COMBINED_CREDITS}`,
)
.then((response) => {
return response?.data;
});
}
async movieRatings(id: number) {
return this.axios
?.get<RTRating>(
`${Endpoints.API_V1}${Endpoints.MOVIE}/${id}${Endpoints.RATINGS}`,
)
.then(({ data }) => data);
}
async tvDetails(id: number) {
return this.axios
?.get<TvDetails>(`${Endpoints.API_V1}${Endpoints.TV}/${id}`)
.then((response) => {
return response?.data;
});
}
async tvRatings(id: number) {
return this.axios
?.get<RTRating>(
`${Endpoints.API_V1}${Endpoints.TV}/${id}${Endpoints.RATINGS}`,
)
.then(({ data }) => data);
}
async tvSeason(id: number, seasonId: number) {
return this.axios
?.get<SeasonWithEpisodes>(
`${Endpoints.API_V1}${Endpoints.TV}/${id}/season/${seasonId}`,
)
.then((response) => {
return response?.data;
});
}
async user(params: any) {
return this.axios
?.get<UserResultsResponse>(`${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<Issue>(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<ServiceCommonServer[]>(
`${Endpoints.API_V1 + Endpoints.SERVICE}/${type}`,
)
.then(({ data }) => data);
}
async serviceDetails(type: "radarr" | "sonarr", id: number) {
return this.axios
?.get<ServiceCommonServerWithDetails>(
`${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(
`Seerr response error\nerror: ${error.toString()}\nurl: ${error?.config?.url}`,
error.response?.data,
);
if (error.response?.status === 403) {
clearJellyseerrStorageData();
}
return Promise.reject(error);
},
);
this.axios.interceptors.request.use(
async (config) => {
const cookies = storage.get<string[]>(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("Seerr request error", error);
},
);
}
}
const jellyseerrUserAtom = atom(storage.get<JellyseerrUser>(JELLYSEERR_USER));
export const useJellyseerr = () => {
const { settings, updateSettings } = useSettings();
const [jellyseerrUser, setJellyseerrUser] = useAtom(jellyseerrUserAtom);
const queryClient = useNetworkAwareQueryClient();
const jellyseerrApi = useMemo(() => {
const cookies = storage.get<string[]>(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 isJellyseerrMovieOrTvResult = (
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,
) => {
return isJellyseerrMovieOrTvResult(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 | PersonCreditCast,
) => {
return new Date(
(isJellyseerrMovieOrTvResult(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 | PersonCreditCast,
): MediaType => {
return isJellyseerrMovieOrTvResult(item)
? (item.mediaType as MediaType)
: item?.mediaInfo?.mediaType;
};
const jellyseerrRegion = useMemo(
// streamingRegion and discoverRegion exists. region doesn't
() => jellyseerrUser?.settings?.discoverRegion || "US",
[jellyseerrUser],
);
const jellyseerrLocale = useMemo(() => {
return jellyseerrUser?.settings?.locale || "en";
}, [jellyseerrUser]);
return {
jellyseerrApi,
jellyseerrUser,
setJellyseerrUser,
clearAllJellyseerData,
isJellyseerrMovieOrTvResult,
getTitle,
getYear,
getMediaType,
jellyseerrRegion,
jellyseerrLocale,
requestMedia,
};
};