From d9cc35b9859a14f44c4e4b13c6518287f8d18e54 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Fri, 21 Nov 2025 19:05:51 +0100 Subject: [PATCH] fix(media-requests): incorrect availability mapping (#4520) --- .../api/src/router/widgets/media-requests.ts | 6 +- packages/integrations/src/index.ts | 2 +- .../media-requests/media-request-types.ts | 68 +++- .../src/jellyseerr/jellyseerr-integration.ts | 11 +- .../src/mock/data/media-request.ts | 19 +- .../src/overseerr/overseerr-integration.ts | 64 +++- packages/translation/src/lang/en.json | 1 + .../src/media-requests/list/component.tsx | 353 +++++++++--------- 8 files changed, 322 insertions(+), 202 deletions(-) diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index d13854e88..a77c89b10 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; +import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import type { MediaRequest } from "@homarr/integrations/types"; import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; @@ -35,7 +36,10 @@ export const mediaRequestsRouter = createTRPCRouter({ return dataB.createdAt.getTime() - dataA.createdAt.getTime(); } - return dataA.status - dataB.status; + return ( + mediaRequestStatusConfiguration[dataA.status].position - + mediaRequestStatusConfiguration[dataB.status].position + ); }); }), subscribeToLatestRequests: publicProcedure diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 5f55faa73..c23aa00b7 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -38,7 +38,7 @@ export type { FirewallMemorySummary, } from "./interfaces/firewall-summary/firewall-summary-types"; export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types"; -export { MediaRequestStatus } from "./interfaces/media-requests/media-request-types"; +export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types"; export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types"; export type { StreamSession } from "./interfaces/media-server/media-server-types"; export type { diff --git a/packages/integrations/src/interfaces/media-requests/media-request-types.ts b/packages/integrations/src/interfaces/media-requests/media-request-types.ts index 15910eaf5..4d7b094ff 100644 --- a/packages/integrations/src/interfaces/media-requests/media-request-types.ts +++ b/packages/integrations/src/interfaces/media-requests/media-request-types.ts @@ -1,3 +1,5 @@ +import { objectKeys } from "@homarr/common"; + interface SerieSeason { id: number; seasonNumber: number; @@ -34,6 +36,64 @@ export interface MediaRequest { requestedBy?: Omit; } +export const mediaAvailabilityConfiguration = { + available: { + color: "green", + }, + partiallyAvailable: { + color: "yellow", + }, + processing: { + color: "blue", + }, + requested: { + color: "violet", + }, + pending: { + color: "violet", + }, + unknown: { + color: "orange", + }, + deleted: { + color: "red", + }, + blacklisted: { + color: "gray", + }, +} satisfies Record; + +export const mediaAvailabilities = objectKeys(mediaAvailabilityConfiguration); + +export type MediaAvailability = (typeof mediaAvailabilities)[number]; + +export const mediaRequestStatusConfiguration = { + pending: { + color: "blue", + position: 1, + }, + approved: { + color: "green", + position: 2, + }, + declined: { + color: "red", + position: 3, + }, + failed: { + color: "red", + position: 4, + }, + completed: { + color: "green", + position: 5, + }, +} satisfies Record; + +export const mediaRequestStatuses = objectKeys(mediaRequestStatusConfiguration); + +export type MediaRequestStatus = (typeof mediaRequestStatuses)[number]; + export interface MediaRequestList { integration: { id: string; @@ -66,7 +126,7 @@ export interface MediaRequestStats { } // https://github.com/fallenbagel/jellyseerr/blob/0fd03f38480f853e7015ad9229ed98160e37602e/server/constants/media.ts#L1 -export enum MediaRequestStatus { +export enum UpstreamMediaRequestStatus { PendingApproval = 1, Approved = 2, Declined = 3, @@ -75,12 +135,12 @@ export enum MediaRequestStatus { } // https://github.com/fallenbagel/jellyseerr/blob/0fd03f38480f853e7015ad9229ed98160e37602e/server/constants/media.ts#L14 -export enum MediaAvailability { +export enum UpstreamMediaAvailability { Unknown = 1, Pending = 2, Processing = 3, PartiallyAvailable = 4, Available = 5, - Blacklisted = 6, - Deleted = 7, + JellyseerrBlacklistedOrOverseerrDeleted = 6, + JellyseerrDeleted = 7, } diff --git a/packages/integrations/src/jellyseerr/jellyseerr-integration.ts b/packages/integrations/src/jellyseerr/jellyseerr-integration.ts index c9e3a6193..eadb2b95f 100644 --- a/packages/integrations/src/jellyseerr/jellyseerr-integration.ts +++ b/packages/integrations/src/jellyseerr/jellyseerr-integration.ts @@ -1,3 +1,12 @@ +import type { MediaAvailability } from "../interfaces/media-requests/media-request-types"; +import { UpstreamMediaAvailability } from "../interfaces/media-requests/media-request-types"; import { OverseerrIntegration } from "../overseerr/overseerr-integration"; -export class JellyseerrIntegration extends OverseerrIntegration {} +export class JellyseerrIntegration extends OverseerrIntegration { + protected override mapAvailability(availability: UpstreamMediaAvailability, inProgress: boolean): MediaAvailability { + // Availability statuses are not exactly the same between Jellyseerr and Overseerr (Jellyseerr has "blacklisted" additionally (deleted is the same value in overseerr)) + if (availability === UpstreamMediaAvailability.JellyseerrBlacklistedOrOverseerrDeleted) return "blacklisted"; + if (availability === UpstreamMediaAvailability.JellyseerrDeleted) return "deleted"; + return super.mapAvailability(availability, inProgress); + } +} diff --git a/packages/integrations/src/mock/data/media-request.ts b/packages/integrations/src/mock/data/media-request.ts index 5137177d8..2f134a8bb 100644 --- a/packages/integrations/src/mock/data/media-request.ts +++ b/packages/integrations/src/mock/data/media-request.ts @@ -1,8 +1,13 @@ -import { objectEntries } from "@homarr/common"; - import type { IMediaRequestIntegration } from "../../interfaces/media-requests/media-request-integration"; -import type { MediaInformation, MediaRequest, RequestStats, RequestUser } from "../../types"; -import { MediaAvailability, MediaRequestStatus } from "../../types"; +import type { + MediaAvailability, + MediaInformation, + MediaRequest, + MediaRequestStatus, + RequestStats, + RequestUser, +} from "../../types"; +import { mediaAvailabilities, mediaRequestStatuses } from "../../types"; export class MediaRequestMockService implements IMediaRequestIntegration { public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number): Promise { @@ -86,12 +91,10 @@ export class MediaRequestMockService implements IMediaRequestIntegration { } private static randomAvailability(): MediaAvailability { - const values = objectEntries(MediaAvailability).filter(([key]) => typeof key === "number"); - return values[Math.floor(Math.random() * values.length)]?.[1] ?? MediaAvailability.Available; + return mediaAvailabilities.at(Math.floor(Math.random() * mediaAvailabilities.length)) ?? "unknown"; } private static randomStatus(): MediaRequestStatus { - const values = objectEntries(MediaRequestStatus).filter(([key]) => typeof key === "number"); - return values[Math.floor(Math.random() * values.length)]?.[1] ?? MediaRequestStatus.PendingApproval; + return mediaRequestStatuses.at(Math.floor(Math.random() * mediaRequestStatuses.length)) ?? "pending"; } } diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index 88ba6239f..0127b2be6 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -9,8 +9,17 @@ import type { ISearchableIntegration } from "../base/searchable-integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; import type { TestingResult } from "../base/test-connection/test-connection-service"; import type { IMediaRequestIntegration } from "../interfaces/media-requests/media-request-integration"; -import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request-types"; -import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request-types"; +import type { + MediaAvailability, + MediaRequest, + MediaRequestStatus, + RequestStats, + RequestUser, +} from "../interfaces/media-requests/media-request-types"; +import { + UpstreamMediaAvailability, + UpstreamMediaRequestStatus, +} from "../interfaces/media-requests/media-request-types"; interface OverseerrSearchResult { id: number; @@ -128,7 +137,7 @@ export class OverseerrIntegration if (pendingResults.length > 0 && allResults.length > 0) { requests = pendingResults.concat( - allResults.filter(({ status }) => status !== MediaRequestStatus.PendingApproval), + allResults.filter(({ status }) => status !== UpstreamMediaRequestStatus.PendingApproval), ); } else if (pendingResults.length > 0) requests = pendingResults; else if (allResults.length > 0) requests = allResults; @@ -137,11 +146,15 @@ export class OverseerrIntegration return await Promise.all( requests.map(async (request): Promise => { const information = await this.getItemInformationAsync(request.media.tmdbId, request.type); + + // 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: request.status, - availability: request.media.status, + 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(), @@ -161,6 +174,42 @@ export class OverseerrIntegration ); } + protected mapRequestStatus(status: UpstreamMediaRequestStatus): MediaRequestStatus { + switch (status) { + case UpstreamMediaRequestStatus.PendingApproval: + return "pending"; + case UpstreamMediaRequestStatus.Approved: + return "approved"; + case UpstreamMediaRequestStatus.Declined: + return "declined"; + case UpstreamMediaRequestStatus.Failed: + return "failed"; + case UpstreamMediaRequestStatus.Completed: + return "completed"; + default: + return "failed"; + } + } + + // See https://github.com/seerr-team/seerr/blob/af083a3cd5c3e3d5d7917fdf4fdd67fe3f39c46b/src/components/StatusBadge/index.tsx#L153-L387 + protected mapAvailability(availability: UpstreamMediaAvailability, inProgress: boolean): MediaAvailability { + switch (availability) { + case UpstreamMediaAvailability.Available: + return inProgress ? "processing" : "available"; + case UpstreamMediaAvailability.PartiallyAvailable: + return inProgress ? "processing" : "partiallyAvailable"; + case UpstreamMediaAvailability.Processing: + return inProgress ? "processing" : "requested"; + case UpstreamMediaAvailability.Pending: + return "pending"; + case UpstreamMediaAvailability.JellyseerrBlacklistedOrOverseerrDeleted: + return "deleted"; + case UpstreamMediaAvailability.Unknown: + default: + return inProgress ? "processing" : "unknown"; + } + } + public async getStatsAsync(): Promise { const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request/count"), { headers: { @@ -339,11 +388,12 @@ const getRequestsSchema = z.object({ .array( z.object({ id: z.number(), - status: z.nativeEnum(MediaRequestStatus), + status: z.enum(UpstreamMediaRequestStatus), createdAt: z.string().transform((value) => new Date(value)), media: z.object({ - status: z.nativeEnum(MediaAvailability), + status: z.enum(UpstreamMediaAvailability), tmdbId: z.number(), + downloadStatus: z.array(z.unknown()).optional(), }), type: z.enum(["movie", "tv"]), requestedBy: z diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index deb14ac2d..82c3bdcfd 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2232,6 +2232,7 @@ "unknown": "Unknown", "pending": "Pending", "processing": "Processing", + "requested": "Requested", "partiallyAvailable": "Partial", "available": "Available", "blacklisted": "Blacklisted", diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index d148ff916..3015451bc 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -3,10 +3,11 @@ import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core"; import { IconThumbDown, IconThumbUp } from "@tabler/icons-react"; +import type { RouterInputs, RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; -import { MediaAvailability, MediaRequestStatus } from "@homarr/integrations/types"; -import type { ScopedTranslationFunction } from "@homarr/translation"; +import type { MediaRequestStatus } from "@homarr/integrations/types"; +import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; @@ -18,7 +19,6 @@ export default function MediaServerWidget({ options, width, }: WidgetComponentProps<"mediaRequests-requestList">) { - const t = useScopedI18n("widget.mediaRequests-requestList"); const [mediaRequests] = clientApi.widget.mediaRequests.getLatestRequests.useSuspenseQuery( { integrationIds, @@ -48,20 +48,18 @@ export default function MediaServerWidget({ return dataB.createdAt.getTime() - dataA.createdAt.getTime(); } - return dataA.status - dataB.status; + return ( + mediaRequestStatusConfiguration[dataA.status].position - + mediaRequestStatusConfiguration[dataB.status].position + ); }); }); }, }, ); - const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation(); - const board = useRequiredBoard(); - if (mediaRequests.length === 0) throw new NoIntegrationDataError(); - const isTiny = width < 256; - return ( {mediaRequests.map((mediaRequest) => ( - - - - - - {!isTiny && ( - - )} - - - - - - {mediaRequest.airDate?.getFullYear() ?? t("toBeDetermined")} - - {!isTiny && ( - - {getAvailabilityProperties(mediaRequest.availability, t).label} - - )} - - - - - {(mediaRequest.requestedBy?.displayName ?? "") || "unknown"} - - - - - - {mediaRequest.name || "unknown"} - - {mediaRequest.status === MediaRequestStatus.PendingApproval ? ( - - - { - mutateRequestAnswer({ - integrationId: mediaRequest.integrationId, - requestId: mediaRequest.id, - answer: "approve", - }); - }} - > - - - - - { - mutateRequestAnswer({ - integrationId: mediaRequest.integrationId, - requestId: mediaRequest.id, - answer: "decline", - }); - }} - > - - - - - ) : ( - - )} - - - - - + request={mediaRequest} + isTiny={width <= 256} + options={options} + /> ))} ); } -const statusMapping = { - [MediaRequestStatus.PendingApproval]: { color: "blue", label: (t) => t("pending") }, - [MediaRequestStatus.Approved]: { color: "green", label: (t) => t("approved") }, - [MediaRequestStatus.Declined]: { color: "red", label: (t) => t("declined") }, - [MediaRequestStatus.Failed]: { color: "red", label: (t) => t("failed") }, - [MediaRequestStatus.Completed]: { color: "green", label: (t) => t("completed") }, -} satisfies Record< - MediaRequestStatus, - { - color: string; - label: (t: ScopedTranslationFunction<"widget.mediaRequests-requestList.status">) => string; - } ->; +interface MediaRequestCardProps { + request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number]; + isTiny: boolean; + options: WidgetComponentProps<"mediaRequests-requestList">["options"]; +} + +const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) => { + const board = useRequiredBoard(); + const t = useScopedI18n("widget.mediaRequests-requestList"); + + return ( + + + + + + {!isTiny && ( + + )} + + + + + + {request.airDate?.getFullYear() ?? t("toBeDetermined")} + + {!isTiny && ( + + {t(`availability.${request.availability}`)} + + )} + + + + + {(request.requestedBy?.displayName ?? "") || "unknown"} + + + + + + {request.name || "unknown"} + + {request.status === "pending" ? ( + + ) : ( + + )} + + + + + + ); +}; + +interface DecisionButtonsProps { + requestId: number; + integrationId: string; +} + +const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) => { + const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation(); + const t = useScopedI18n("widget.mediaRequests-requestList"); + const handleDecision = (answer: RouterInputs["widget"]["mediaRequests"]["answerRequest"]["answer"]) => { + mutateRequestAnswer({ + integrationId, + requestId, + answer, + }); + }; + + return ( + + + { + handleDecision("approve"); + }} + > + + + + + { + handleDecision("decline"); + }} + > + + + + + ); +}; interface StatusBadgeProps { status: MediaRequestStatus; } const StatusBadge = ({ status }: StatusBadgeProps) => { - const { color, label } = statusMapping[status]; const tStatus = useScopedI18n("widget.mediaRequests-requestList.status"); return ( - - {label(tStatus)} + + {tStatus(status)} ); }; - -function getAvailabilityProperties( - mediaRequestAvailability: MediaAvailability, - t: ScopedTranslationFunction<"widget.mediaRequests-requestList">, -) { - switch (mediaRequestAvailability) { - case MediaAvailability.Available: - return { color: "green", label: t("availability.available") }; - case MediaAvailability.PartiallyAvailable: - return { color: "yellow", label: t("availability.partiallyAvailable") }; - case MediaAvailability.Processing: - return { color: "blue", label: t("availability.processing") }; - case MediaAvailability.Pending: - return { color: "violet", label: t("availability.pending") }; - case MediaAvailability.Blacklisted: - return { color: "gray", label: t("availability.blacklisted") }; - case MediaAvailability.Deleted: - return { color: "red", label: t("availability.deleted") }; - default: - return { color: "orange", label: t("availability.unknown") }; - } -}