mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
Updates branding and naming conventions to use "Seerr" instead of "Jellyseerr" across all files, components, hooks, and translations. Renames files, functions, classes, variables, and UI text to reflect the new naming convention while maintaining identical functionality. Updates asset references including logo and screenshot images. Changes API class name, storage keys, atom names, and all related utilities to use "Seerr" prefix. Modifies translation keys and user-facing text to match the rebrand.
548 lines
15 KiB
TypeScript
548 lines
15 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 SeerrUser } 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 SEERR_USER = "SEERR_USER";
|
|
const SEERR_COOKIES = "SEERR_COOKIES";
|
|
|
|
export const clearSeerrStorageData = () => {
|
|
storage.remove(SEERR_USER);
|
|
storage.remove(SEERR_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 SeerrApi {
|
|
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<SeerrUser>(SEERR_USER);
|
|
const cookies = storage.get<string[]>(SEERR_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("seerr.toasts.seer_does_not_meet_requirements");
|
|
toast.error(error);
|
|
throw Error(error);
|
|
}
|
|
|
|
storage.setAny(
|
|
SEERR_COOKIES,
|
|
headers["set-cookie"]?.flatMap((c) => c.split("; ")) ?? [],
|
|
);
|
|
return {
|
|
isValid: true,
|
|
requiresPass: true,
|
|
};
|
|
}
|
|
toast.error(t("seerr.toasts.seerr_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("seerr.toasts.failed_to_test_seerr_server_url");
|
|
toast.error(msg);
|
|
console.error(msg, e);
|
|
return {
|
|
isValid: false,
|
|
requiresPass: false,
|
|
};
|
|
});
|
|
}
|
|
|
|
async login(username: string, password: string): Promise<SeerrUser> {
|
|
return this.axios
|
|
?.post<SeerrUser>(Endpoints.API_V1 + Endpoints.AUTH_JELLYFIN, {
|
|
username,
|
|
password,
|
|
email: username,
|
|
})
|
|
.then((response) => {
|
|
const user = response?.data;
|
|
if (!user) throw Error("Login failed");
|
|
storage.setAny(SEERR_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("seerr.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(
|
|
SEERR_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) {
|
|
clearSeerrStorageData();
|
|
}
|
|
return Promise.reject(error);
|
|
},
|
|
);
|
|
|
|
this.axios.interceptors.request.use(
|
|
async (config) => {
|
|
const cookies = storage.get<string[]>(SEERR_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 seerrUserAtom = atom(storage.get<SeerrUser>(SEERR_USER));
|
|
|
|
export const useSeerr = () => {
|
|
const { settings, updateSettings } = useSettings();
|
|
const [seerrUser, setSeerrUser] = useAtom(seerrUserAtom);
|
|
const queryClient = useNetworkAwareQueryClient();
|
|
|
|
const seerrApi = useMemo(() => {
|
|
const cookies = storage.get<string[]>(SEERR_COOKIES);
|
|
if (settings?.seerrServerUrl && cookies && seerrUser) {
|
|
return new SeerrApi(settings?.seerrServerUrl);
|
|
}
|
|
return undefined;
|
|
}, [settings?.seerrServerUrl, seerrUser]);
|
|
|
|
const clearAllSeerrData = useCallback(async () => {
|
|
clearSeerrStorageData();
|
|
setSeerrUser(undefined);
|
|
updateSettings({ seerrServerUrl: undefined });
|
|
}, []);
|
|
|
|
const requestMedia = useCallback(
|
|
(title: string, request: MediaRequestBody, onSuccess?: () => void) => {
|
|
seerrApi?.request?.(request)?.then(async (mediaRequest) => {
|
|
await queryClient.invalidateQueries({
|
|
queryKey: ["search", "seerr"],
|
|
});
|
|
|
|
switch (mediaRequest.status) {
|
|
case MediaRequestStatus.PENDING:
|
|
case MediaRequestStatus.APPROVED:
|
|
toast.success(t("seerr.toasts.requested_item", { item: title }));
|
|
onSuccess?.();
|
|
break;
|
|
case MediaRequestStatus.DECLINED:
|
|
toast.error(t("seerr.toasts.you_dont_have_permission_to_request"));
|
|
break;
|
|
case MediaRequestStatus.FAILED:
|
|
toast.error(
|
|
t("seerr.toasts.something_went_wrong_requesting_media"),
|
|
);
|
|
break;
|
|
}
|
|
});
|
|
},
|
|
[seerrApi],
|
|
);
|
|
|
|
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 getTitle = (
|
|
item?: TvResult | TvDetails | MovieResult | MovieDetails | PersonCreditCast,
|
|
) => {
|
|
return isSeerrMovieOrTvResult(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(
|
|
(isSeerrMovieOrTvResult(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 isSeerrMovieOrTvResult(item)
|
|
? (item.mediaType as MediaType)
|
|
: item?.mediaInfo?.mediaType;
|
|
};
|
|
|
|
const seerrRegion = useMemo(
|
|
// streamingRegion and discoverRegion exists. region doesn't
|
|
() => seerrUser?.settings?.discoverRegion || "US",
|
|
[seerrUser],
|
|
);
|
|
|
|
const seerrLocale = useMemo(() => {
|
|
return seerrUser?.settings?.locale || "en";
|
|
}, [seerrUser]);
|
|
|
|
return {
|
|
seerrApi,
|
|
seerrUser,
|
|
setSeerrUser,
|
|
clearAllSeerrData,
|
|
isSeerrMovieOrTvResult,
|
|
getTitle,
|
|
getYear,
|
|
getMediaType,
|
|
seerrRegion,
|
|
seerrLocale,
|
|
requestMedia,
|
|
};
|
|
};
|