feat: add media requests widget (#774)

Co-authored-by: SeDemal <Tagaishi@hotmail.ch>
Co-authored-by: SeDemal <demal.sebastien@bluewin.ch>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2024-08-24 15:23:16 +02:00
committed by GitHub
parent 7ec4adcb24
commit acbb834889
30 changed files with 1106 additions and 29 deletions

View File

@@ -0,0 +1,241 @@
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;
}),
});