feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -0,0 +1,39 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { OverseerrIntegration } from "./overseerr-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new OverseerrIntegration({
id: "overseerr-1",
name: "Overseerr",
url: "https://overseerr.example.com",
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: "test-key" }],
});
describe("Overseerr media request actions", () => {
beforeEach(() => vi.clearAllMocks());
it.each([
["approve", () => integration.approveRequestAsync(42)],
["decline", () => integration.declineRequestAsync(42)],
])("rejects %s when Overseerr returns a non-success response", async (_action, run) => {
mockFetch.mockResolvedValue(new Response(null, { status: 500 }));
await expect(run()).rejects.toMatchObject({
name: "IntegrationResponseError",
message: "Response from integration did not indicate success",
cause: {
name: "ResponseError",
message: "Response did not indicate success",
statusCode: 500,
},
});
expect(mockFetch).toHaveBeenCalledOnce();
});
});
@@ -1,9 +1,12 @@
import type { Dispatcher } from "undici";
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { ResponseError } from "@homarr/common/server";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { IntegrationHttpAuthentication } from "../http-auth";
import { apiKeyAuth } from "../http-auth";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { ISearchableIntegration } from "../base/searchable-integration";
@@ -40,6 +43,10 @@ export class OverseerrIntegration
extends Integration
implements IMediaRequestIntegration, ISearchableIntegration<OverseerrSearchResult>
{
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
return apiKeyAuth(this.integration);
}
public async searchAsync(query: string) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/search", { query }), {
headers: {
@@ -123,77 +130,84 @@ export class OverseerrIntegration
}
public async getRequestsAsync(): Promise<MediaRequest[]> {
const pendingRequests = await fetchWithTrustedCertificatesAsync(
this.url("/api/v1/request", { take: 20, filter: "pending" }),
{
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
);
// One load shares certificate setup, sockets and TLS sessions. A later load
// still reads current certificate settings, and no dispatcher is retained.
const dispatcher = await createCertificateAgentAsync();
try {
const fetchRequests = async (parameters: { take: number; filter?: string; sort?: string }) => {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", parameters), {
headers: { "X-Api-Key": this.getSecretValue("apiKey") },
dispatcher,
});
return (await getRequestsSchema.parseAsync(await response.json())).results;
};
const [pendingResults, allResults] = await Promise.all([
fetchRequests({ take: 20, filter: "pending" }),
fetchRequests({ take: 20, sort: "modified" }),
]);
const allRequests = await fetchWithTrustedCertificatesAsync(
this.url("/api/v1/request", { take: 20, sort: "modified" }),
{
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
);
//Concat the 2 lists while remove any duplicate pending from the all items list
let requests;
const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results;
const allResults = (await getRequestsSchema.parseAsync(await allRequests.json())).results;
if (pendingResults.length > 0 && allResults.length > 0) {
requests = pendingResults.concat(
allResults.filter(({ status }) => status !== UpstreamMediaRequestStatus.PendingApproval),
);
} else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults;
else return [];
//Concat the 2 lists while remove any duplicate pending from the all items list
let requests;
// Requests for different users can reference the same movie or series. Share
// its in-flight lookup within this load, without retaining stale metadata.
const informationByMedia = new Map<string, Promise<MediaInformation>>();
const settled = await Promise.allSettled(
requests.map(async (request): Promise<MediaRequest> => {
const mediaKey = `${request.type}:${request.media.tmdbId}`;
let informationPromise = informationByMedia.get(mediaKey);
if (!informationPromise) {
informationPromise = this.getItemInformationAsync(request.media.tmdbId, request.type, dispatcher);
informationByMedia.set(mediaKey, informationPromise);
}
const information = await informationPromise;
if (pendingResults.length > 0 && allResults.length > 0) {
requests = pendingResults.concat(
allResults.filter(({ status }) => status !== UpstreamMediaRequestStatus.PendingApproval),
// See https://github.com/seerr-team/seerr/blob/af083a3cd5c3e3d5d7917fdf4fdd67fe3f39c46b/src/components/StatusBadge/index.tsx#L40
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,
};
}),
);
} else if (pendingResults.length > 0) requests = pendingResults;
else if (allResults.length > 0) requests = allResults;
else return [];
const settled = await Promise.allSettled(
requests.map(async (request): Promise<MediaRequest> => {
const information = await this.getItemInformationAsync(request.media.tmdbId, request.type);
const fulfilled = settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value);
// See https://github.com/seerr-team/seerr/blob/af083a3cd5c3e3d5d7917fdf4fdd67fe3f39c46b/src/components/StatusBadge/index.tsx#L40
const inProgress = (request.media.downloadStatus ?? []).length >= 1;
if (fulfilled.length === 0) {
throw new Error("Failed to resolve any media request information");
}
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,
};
}),
);
const fulfilled = settled
.filter((result): result is PromiseFulfilledResult<MediaRequest> => result.status === "fulfilled")
.map((result) => result.value);
if (fulfilled.length === 0) {
throw new Error("Failed to resolve any media request information");
return fulfilled;
} finally {
await dispatcher.close();
}
return fulfilled;
}
protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus {
@@ -259,59 +273,43 @@ export class OverseerrIntegration
public async approveRequestAsync(requestId: number): Promise<void> {
logger.info("Approving media request", { requestId, integration: this.integration.name });
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
const response = 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 });
});
if (!response.ok) throw new ResponseError(response);
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 });
await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
const response = 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 });
});
if (!response.ok) throw new ResponseError(response);
logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
}
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise<MediaInformation> {
private async getItemInformationAsync(
id: number,
type: MediaRequest["type"],
dispatcher: Dispatcher,
): Promise<MediaInformation> {
const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/${type}/${id}`), {
dispatcher,
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`Failed to fetch ${type} information for id ${id}: ${response.status} ${response.statusText}`);
}