Co-authored-by: SeDemal <Tagaishi@hotmail.ch> Co-authored-by: SeDemal <demal.sebastien@bluewin.ch> Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
242 lines
7.3 KiB
TypeScript
242 lines
7.3 KiB
TypeScript
import { z } from "@homarr/validation";
|
|
|
|
import { Integration } from "../base/integration";
|
|
import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request";
|
|
import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request";
|
|
|
|
/**
|
|
* Overseerr Integration. See https://api-docs.overseerr.dev
|
|
*/
|
|
export class OverseerrIntegration extends Integration {
|
|
public async testConnectionAsync(): Promise<void> {
|
|
const response = await fetch(`${this.integration.url}/api/v1/auth/me`, {
|
|
headers: {
|
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
},
|
|
});
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
const json: object = await response.json();
|
|
if (Object.keys(json).includes("id")) {
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Received response but unable to parse it: ${JSON.stringify(json)}`);
|
|
}
|
|
|
|
public async getRequestsAsync(): Promise<MediaRequest[]> {
|
|
//Ensure to get all pending request first
|
|
const pendingRequests = await fetch(`${this.integration.url}/api/v1/request?take=-1&filter=pending`, {
|
|
headers: {
|
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
},
|
|
});
|
|
|
|
//Change 20 to integration setting (set to -1 for all)
|
|
const allRequests = await fetch(`${this.integration.url}/api/v1/request?take=20`, {
|
|
headers: {
|
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
},
|
|
});
|
|
|
|
const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results;
|
|
const allResults = (await getRequestsSchema.parseAsync(await allRequests.json())).results;
|
|
|
|
//Concat the 2 lists while remove any duplicate pending from the all items list
|
|
let requests;
|
|
|
|
if (pendingResults.length > 0 && allResults.length > 0) {
|
|
requests = pendingResults.concat(
|
|
allResults.filter(({ status }) => status !== MediaRequestStatus.PendingApproval),
|
|
);
|
|
} else if (pendingResults.length > 0) requests = pendingResults;
|
|
else if (allResults.length > 0) requests = allResults;
|
|
else return Promise.all([]);
|
|
|
|
return await Promise.all(
|
|
requests.map(async (request): Promise<MediaRequest> => {
|
|
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
|
|
return {
|
|
id: request.id,
|
|
name: information.name,
|
|
status: request.status,
|
|
availability: request.media.status,
|
|
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
|
|
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
|
|
href: `${this.integration.url}/${request.type}/${request.media.tmdbId}`,
|
|
type: request.type,
|
|
createdAt: request.createdAt,
|
|
airDate: new Date(information.airDate),
|
|
requestedBy: request.requestedBy
|
|
? ({
|
|
...request.requestedBy,
|
|
displayName: request.requestedBy.displayName,
|
|
link: `${this.integration.url}/users/${request.requestedBy.id}`,
|
|
avatar: constructAvatarUrl(this.integration.url, request.requestedBy.avatar),
|
|
} satisfies Omit<RequestUser, "requestCount">)
|
|
: undefined,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
public async getStatsAsync(): Promise<RequestStats> {
|
|
const response = await fetch(`${this.integration.url}/api/v1/request/count`, {
|
|
headers: {
|
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
},
|
|
});
|
|
return await getStatsSchema.parseAsync(await response.json());
|
|
}
|
|
|
|
public async getUsersAsync(): Promise<RequestUser[]> {
|
|
const response = await fetch(`${this.integration.url}/api/v1/user?take=-1`, {
|
|
headers: {
|
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
},
|
|
});
|
|
const users = (await getUsersSchema.parseAsync(await response.json())).results;
|
|
return users.map((user): RequestUser => {
|
|
return {
|
|
...user,
|
|
link: `${this.integration.url}/users/${user.id}`,
|
|
avatar: constructAvatarUrl(this.integration.url, user.avatar),
|
|
};
|
|
});
|
|
}
|
|
|
|
public async approveRequestAsync(requestId: number): Promise<void> {
|
|
await fetch(`${this.integration.url}/api/v1/request/${requestId}/approve`, {
|
|
method: "POST",
|
|
headers: {
|
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
},
|
|
});
|
|
}
|
|
|
|
public async declineRequestAsync(requestId: number): Promise<void> {
|
|
await fetch(`${this.integration.url}/api/v1/request/${requestId}/decline`, {
|
|
method: "POST",
|
|
headers: {
|
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
},
|
|
});
|
|
}
|
|
|
|
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
|
|
const response = await fetch(`${this.integration.url}/api/v1/${type}/${id}`, {
|
|
headers: {
|
|
"X-Api-Key": this.getSecretValue("apiKey"),
|
|
},
|
|
});
|
|
|
|
if (type === "tv") {
|
|
const series = (await response.json()) as TvInformation;
|
|
return {
|
|
name: series.name,
|
|
backdropPath: series.backdropPath ?? series.posterPath,
|
|
posterPath: series.posterPath ?? series.backdropPath,
|
|
airDate: series.firstAirDate,
|
|
} satisfies MediaInformation;
|
|
}
|
|
|
|
const movie = (await response.json()) as MovieInformation;
|
|
return {
|
|
name: movie.title,
|
|
backdropPath: movie.backdropPath ?? movie.posterPath,
|
|
posterPath: movie.posterPath ?? movie.backdropPath,
|
|
airDate: movie.releaseDate,
|
|
} satisfies MediaInformation;
|
|
}
|
|
}
|
|
|
|
const constructAvatarUrl = (appUrl: string, avatar: string) => {
|
|
const isAbsolute = avatar.startsWith("http://") || avatar.startsWith("https://");
|
|
|
|
if (isAbsolute) {
|
|
return avatar;
|
|
}
|
|
|
|
return `${appUrl}/${avatar}`;
|
|
};
|
|
|
|
interface MediaInformation {
|
|
name: string;
|
|
backdropPath?: string;
|
|
posterPath?: string;
|
|
airDate: string;
|
|
}
|
|
|
|
interface TvInformation {
|
|
name: string;
|
|
backdropPath?: string;
|
|
posterPath?: string;
|
|
firstAirDate: string;
|
|
}
|
|
|
|
interface MovieInformation {
|
|
title: string;
|
|
backdropPath?: string;
|
|
posterPath?: string;
|
|
releaseDate: string;
|
|
}
|
|
|
|
const getRequestsSchema = z.object({
|
|
results: z
|
|
.array(
|
|
z.object({
|
|
id: z.number(),
|
|
status: z.nativeEnum(MediaRequestStatus),
|
|
createdAt: z.string().transform((value) => new Date(value)),
|
|
media: z.object({
|
|
status: z.nativeEnum(MediaAvailability),
|
|
tmdbId: z.number(),
|
|
}),
|
|
type: z.enum(["movie", "tv"]),
|
|
requestedBy: z
|
|
.object({
|
|
id: z.number(),
|
|
displayName: z.string(),
|
|
avatar: z.string(),
|
|
})
|
|
.optional(),
|
|
}),
|
|
)
|
|
.optional()
|
|
.transform((val) => {
|
|
if (!val) {
|
|
return [];
|
|
}
|
|
return val;
|
|
}),
|
|
});
|
|
|
|
const getStatsSchema = z.object({
|
|
total: z.number(),
|
|
movie: z.number(),
|
|
tv: z.number(),
|
|
pending: z.number(),
|
|
approved: z.number(),
|
|
declined: z.number(),
|
|
processing: z.number(),
|
|
available: z.number(),
|
|
});
|
|
|
|
const getUsersSchema = z.object({
|
|
results: z
|
|
.array(
|
|
z.object({
|
|
id: z.number(),
|
|
displayName: z.string(),
|
|
avatar: z.string(),
|
|
requestCount: z.number(),
|
|
}),
|
|
)
|
|
.optional()
|
|
.transform((val) => {
|
|
if (!val) {
|
|
return [];
|
|
}
|
|
return val;
|
|
}),
|
|
});
|