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

@@ -3,23 +3,29 @@ import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
import type { IntegrationInput } from "./integration";
import type { Integration, IntegrationInput } from "./integration";
export const integrationCreatorByKind = (kind: IntegrationKind, integration: IntegrationInput) => {
switch (kind) {
case "piHole":
return new PiHoleIntegration(integration);
case "adGuardHome":
return new AdGuardHomeIntegration(integration);
case "homeAssistant":
return new HomeAssistantIntegration(integration);
case "jellyfin":
return new JellyfinIntegration(integration);
case "sonarr":
return new SonarrIntegration(integration);
default:
throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`);
export const integrationCreatorByKind = <TKind extends keyof typeof integrationCreators>(
kind: TKind,
integration: IntegrationInput,
) => {
if (!(kind in integrationCreators)) {
throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`);
}
return new integrationCreators[kind](integration) as InstanceType<(typeof integrationCreators)[TKind]>;
};
export const integrationCreators = {
piHole: PiHoleIntegration,
adGuardHome: AdGuardHomeIntegration,
homeAssistant: HomeAssistantIntegration,
jellyfin: JellyfinIntegration,
sonarr: SonarrIntegration,
jellyseerr: JellyseerrIntegration,
overseerr: OverseerrIntegration,
} satisfies Partial<Record<IntegrationKind, new (integration: IntegrationInput) => Integration>>;

View File

@@ -3,10 +3,14 @@ export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
// Types
export type { StreamSession } from "./interfaces/media-server/session";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request";
// Helpers
export { integrationCreatorByKind } from "./base/creator";

View File

@@ -0,0 +1,62 @@
export interface MediaRequest {
id: number;
name: string;
type: "movie" | "tv";
backdropImageUrl: string;
posterImagePath: string;
href: string;
createdAt: Date;
airDate?: Date;
status: MediaRequestStatus;
availability: MediaAvailability;
requestedBy?: Omit<RequestUser, "requestCount">;
}
export interface MediaRequestList {
integration: {
id: string;
};
medias: MediaRequest[];
}
export interface RequestStats {
total: number;
movie: number;
tv: number;
pending: number;
approved: number;
declined: number;
processing: number;
available: number;
}
export interface RequestUser {
id: number;
displayName: string;
avatar: string;
requestCount: number;
link: string;
}
export interface MediaRequestStats {
integration: {
kind: string;
name: string;
};
stats: RequestStats;
users: RequestUser[];
}
export enum MediaRequestStatus {
PendingApproval = 1,
Approved = 2,
Declined = 3,
}
export enum MediaAvailability {
Unknown = 1,
Pending = 2,
Processing = 3,
PartiallyAvailable = 4,
Available = 5,
}

View File

@@ -0,0 +1,3 @@
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
export class JellyseerrIntegration extends OverseerrIntegration {}

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;
}),
});

View File

@@ -1,2 +1,3 @@
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./calendar-types";
export * from "./interfaces/media-requests/media-request";