Compare commits

..
5 changed files with 147 additions and 180 deletions

No files matched your search

@@ -14,9 +14,7 @@ export const mediaRequestStatsJob = createCronJob("mediaRequestStats", EVERY_MIN
}),
);
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE, {
expectedMaximumDurationInMillis: 15_000,
}).withCallback(
export const mediaRequestListJob = createCronJob("mediaRequestList", EVERY_MINUTE).withCallback(
createRequestIntegrationJobHandler(mediaRequestListRequestHandler.handler, {
widgetKinds: ["mediaRequests-requestList"],
getInput: {
@@ -12,14 +12,14 @@ interface SeriesInformation {
overview: string;
seasons: SerieSeason[];
posterPath: string;
requestedSeasons: number[];
requestedSeasons?: number[];
}
interface MovieInformation {
id: number;
overview: string;
posterPath: string;
requestedSeasons: number[];
requestedSeasons?: number[];
}
export type MediaInformation = SeriesInformation | MovieInformation;
@@ -1,10 +1,6 @@
import { z } from "zod/v4";
import type { RequestInit, Response } from "undici";
import { fetch } from "undici";
import { splitToChunksWithNItems } from "@homarr/common";
import { createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
@@ -28,14 +24,6 @@ import {
const logger = createLogger({ module: "overseerrIntegration" });
const REQUEST_TIMEOUT_MS = 10_000;
const DETAIL_FETCH_BATCH_SIZE = 5;
interface OverseerrFetchClient {
fetchAsync: (url: URL, init?: RequestInit) => Promise<Response>;
fetchUncheckedAsync: (url: URL, init?: RequestInit) => Promise<Response>;
}
interface OverseerrSearchResult {
id: number;
name: string;
@@ -52,43 +40,12 @@ export class OverseerrIntegration
extends Integration
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
{
private async createFetchClientAsync(): Promise<OverseerrFetchClient> {
const agent = await createCertificateAgentAsync(undefined);
const apiKey = this.getSecretValue("apiKey");
const requestAsync = async (url: URL, init?: RequestInit) => {
return await withTimeoutAsync(
(signal) =>
fetch(url, {
...init,
signal,
dispatcher: agent,
headers: {
"X-Api-Key": apiKey,
...init?.headers,
},
}),
REQUEST_TIMEOUT_MS,
);
};
return {
fetchAsync: async (url, init) => {
const response = await requestAsync(url, init);
if (!response.ok) {
throw new Error(`Overseerr request failed: ${response.status} ${response.statusText} (${url.toString()})`);
}
return response;
},
fetchUncheckedAsync: requestAsync,
};
}
public async searchAsync(query: string) {
const client = await this.createFetchClientAsync();
const response = await client.fetchAsync(this.url("/api/v1/search", { query }));
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const schemaData = await searchSchema.parseAsync(await response.json());
if (!schemaData.results) {
@@ -110,9 +67,12 @@ export class OverseerrIntegration
}
public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) {
const client = await this.createFetchClientAsync();
const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`);
const response = await client.fetchAsync(url);
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const data = await mediaInformationSchema.parseAsync(await response.json());
const requestedSeasons = [
...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []),
@@ -128,9 +88,8 @@ export class OverseerrIntegration
* @param seasons A list of the seasons that should be requested.
*/
public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise<void> {
const client = await this.createFetchClientAsync();
const url = this.url("/api/v1/request");
const response = await client.fetchUncheckedAsync(url, {
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
body: JSON.stringify({
mediaType,
@@ -138,6 +97,7 @@ export class OverseerrIntegration
seasons,
}),
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
"Content-Type": "application/json",
},
});
@@ -163,16 +123,25 @@ export class OverseerrIntegration
}
public async getRequestsAsync(): Promise<MediaRequest[]> {
const client = await this.createFetchClientAsync();
const pendingRequests = await fetchWithTrustedCertificatesAsync(
this.url("/api/v1/request", { take: 20, filter: "pending" }),
{
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
);
const [pendingResponse, allResponse] = await Promise.all([
client.fetchAsync(this.url("/api/v1/request", { take: 20, filter: "pending" })),
client.fetchAsync(this.url("/api/v1/request", { take: 20 })),
]);
const allRequests = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", { take: 20 }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const pendingResults = (await getRequestsSchema.parseAsync(await pendingResponse.json())).results;
const allResults = (await getRequestsSchema.parseAsync(await allResponse.json())).results;
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) {
@@ -181,53 +150,37 @@ export class OverseerrIntegration
);
} else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults;
else return [];
else return Promise.all([]);
const uniqueKeys = [...new Set(requests.map((request) => `${request.type}:${request.media.tmdbId}`))];
const informationByKey = new Map<string, MediaInformation>();
return await Promise.all(
requests.map(async (request): Promise<MediaRequest> => {
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
for (const batch of splitToChunksWithNItems(uniqueKeys, DETAIL_FETCH_BATCH_SIZE)) {
const batchResults = await Promise.all(
batch.map(async (key) => {
const separatorIndex = key.indexOf(":");
const type = key.slice(0, separatorIndex) as MediaRequest["type"];
const tmdbId = Number(key.slice(separatorIndex + 1));
const information = await this.getItemInformationAsync(client, type, tmdbId);
return [key, information] as const;
}),
);
// See https://github.com/seerr-team/seerr/blob/af083a3cd5c3e3d5d7917fdf4fdd67fe3f39c46b/src/components/StatusBadge/index.tsx#L40
const inProgress = (request.media.downloadStatus ?? []).length >= 1;
for (const [key, information] of batchResults) {
informationByKey.set(key, information);
}
}
return requests.map((request): MediaRequest => {
const information = informationByKey.get(`${request.type}:${request.media.tmdbId}`)!;
const inProgress = (request.media.downloadStatus ?? []).length >= 1;
return {
id: request.id,
name: information.name,
status: this.mapRequestStatus(request.status),
availability: this.mapAvailability(request.media.status, inProgress),
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.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
type: request.type,
createdAt: request.createdAt,
airDate: new Date(information.airDate),
requestedBy: request.requestedBy
? ({
...request.requestedBy,
displayName: request.requestedBy.displayName,
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
} satisfies Omit<RequestUser, "requestCount">)
: undefined,
};
});
return {
id: request.id,
name: information.name,
status: this.mapRequestStatus(request.status),
availability: this.mapAvailability(request.media.status, inProgress),
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.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
type: request.type,
createdAt: request.createdAt,
airDate: new Date(information.airDate),
requestedBy: request.requestedBy
? ({
...request.requestedBy,
displayName: request.requestedBy.displayName,
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
} satisfies Omit<RequestUser, "requestCount">)
: undefined,
};
}),
);
}
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -267,14 +220,20 @@ export class OverseerrIntegration
}
public async getStatsAsync(): Promise<RequestStats> {
const client = await this.createFetchClientAsync();
const response = await client.fetchAsync(this.url("/api/v1/request/count"));
const response = await fetchWithTrustedCertificatesAsync(this.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 client = await this.createFetchClientAsync();
const response = await client.fetchAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }));
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/user", { take: 10, sort: "requests" }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
const users = (await getUsersSchema.parseAsync(await response.json())).results;
return users.map((user): RequestUser => {
return {
@@ -287,58 +246,75 @@ export class OverseerrIntegration
public async approveRequestAsync(requestId: number): Promise<void> {
logger.info("Approving media request", { requestId, integration: this.integration.name });
const client = await this.createFetchClientAsync();
await client
.fetchUncheckedAsync(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
})
.then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to approve media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
}).then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to approve media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
});
logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
});
}
public async declineRequestAsync(requestId: number): Promise<void> {
logger.info("Declining media request", { requestId, integration: this.integration.name });
const client = await this.createFetchClientAsync();
await client
.fetchUncheckedAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
})
.then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to decline media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
});
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
}).then((response) => {
if (!response.ok) {
logger.error(
new ErrorWithMetadata("Failed to decline media request", {
requestId,
integration: this.integration.name,
reason: `${response.status} ${response.statusText}`,
url: response.url,
}),
);
}
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
});
}
private async getItemInformationAsync(
client: OverseerrFetchClient,
type: MediaRequest["type"],
id: number,
): Promise<MediaInformation> {
const response = await client.fetchAsync(this.url(`/api/v1/${type}/${id}`));
const rawData = (await response.json()) as TvInformation | MovieInformation;
return mediaInformationFromResponse[type](rawData as never);
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
const response = await fetchWithTrustedCertificatesAsync(this.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;
}
private constructAvatarUrl(avatar: string) {
@@ -373,21 +349,6 @@ interface MovieInformation {
releaseDate: string;
}
const mediaInformationFromResponse = {
tv: (series: TvInformation): MediaInformation => ({
name: series.name,
backdropPath: series.backdropPath ?? series.posterPath,
posterPath: series.posterPath ?? series.backdropPath,
airDate: series.firstAirDate,
}),
movie: (movie: MovieInformation): MediaInformation => ({
name: movie.title,
backdropPath: movie.backdropPath ?? movie.posterPath,
posterPath: movie.posterPath ?? movie.backdropPath,
airDate: movie.releaseDate,
}),
} satisfies Record<MediaRequest["type"], (data: TvInformation | MovieInformation) => MediaInformation>;
const mediaInfoRequestsSchema = z
.object({
requests: z
@@ -15,13 +15,8 @@ import { createGroup } from "../../lib/group";
import { mediaRequestSearchScopeAtom } from "../../spotlight-store";
import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group";
const availabilityLabels: Partial<Record<MediaAvailability, string>> = {
available: "Available",
partiallyAvailable: "Partial",
processing: "Processing",
requested: "Requested",
pending: "Requested",
};
const availabilityLabelKeys = ["available", "partiallyAvailable", "processing", "requested", "pending"] as const;
type AvailabilityWithLabel = (typeof availabilityLabelKeys)[number];
type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequests"][number];
@@ -42,6 +37,8 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
keyPath: "key",
title: (t) => t("search.mode.media.group.title"),
Component(option) {
const tMedia = useScopedI18n("search.mode.media");
if (option.kind !== "result") {
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs" opacity={option.kind === "disabled" ? 0.55 : 1}>
@@ -57,7 +54,11 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
}
const { result } = option;
const badgeLabel = result.availability ? availabilityLabels[result.availability as MediaAvailability] : undefined;
const hasAvailabilityLabel =
result.availability && (availabilityLabelKeys as readonly string[]).includes(result.availability);
const badgeLabel = hasAvailabilityLabel
? tMedia(`availability.${result.availability as AvailabilityWithLabel}`)
: undefined;
const badgeColor = result.availability
? mediaAvailabilityConfiguration[result.availability as MediaAvailability]?.color
: undefined;
+7
View File
@@ -4567,6 +4567,13 @@
"scoped": "This widget does not have an accessible media request integration."
}
}
},
"availability": {
"available": "Available",
"partiallyAvailable": "Partial",
"processing": "Processing",
"requested": "Requested",
"pending": "Requested"
}
},
"external": {