feat(media-request): add availability indicators and allow TV re-requests

- Extend searchSchema to parse mediaInfo.status and map to
  MediaAvailability (available, partial, processing, requested)
- Extend mediaInformationSchema to parse already-requested seasons
  from mediaInfo.requests[].seasons[].seasonNumber
- Show colored availability badge on search results in spotlight
- Allow re-requesting TV shows (hide Request only for fully available
  movies), so users can request additional seasons
This commit is contained in:
Thomas Camlong committed 2026-05-30 00:56:32 +02:00
1 parent 8a0c438a7d
commit 3e856c648f
4 files changed
+54 -8

No files matched your search

@@ -12,12 +12,14 @@ interface SeriesInformation {
overview: string;
seasons: SerieSeason[];
posterPath: string;
requestedSeasons: number[];
}
interface MovieInformation {
id: number;
overview: string;
posterPath: string;
requestedSeasons: number[];
}
export type MediaInformation = SeriesInformation | MovieInformation;
@@ -60,6 +60,9 @@ export class OverseerrIntegration
text: "overview" in result ? result.overview : undefined,
type: result.mediaType,
inLibrary: result.mediaInfo !== undefined,
availability: result.mediaInfo
? this.mapAvailability(result.mediaInfo.status as UpstreamMediaAvailability, false)
: undefined,
}));
}
@@ -70,7 +73,14 @@ export class OverseerrIntegration
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
return await mediaInformationSchema.parseAsync(await response.json());
const data = await mediaInformationSchema.parseAsync(await response.json());
const requestedSeasons = [
...new Set(
data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? [],
),
];
const { mediaInfo: _strip, ...rest } = data;
return { ...rest, requestedSeasons };
}
/**
@@ -341,6 +351,14 @@ interface MovieInformation {
releaseDate: string;
}
const mediaInfoRequestsSchema = z.object({
requests: z.array(z.object({
seasons: z.array(z.object({
seasonNumber: z.number(),
})),
})).optional(),
}).optional();
const mediaInformationSchema = z.union([
z.object({
id: z.number(),
@@ -355,14 +373,20 @@ const mediaInformationSchema = z.union([
),
numberOfSeasons: z.number(),
posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}),
z.object({
id: z.number(),
overview: z.string(),
posterPath: z.string().startsWith("/"),
mediaInfo: mediaInfoRequestsSchema,
}),
]);
const searchMediaInfoSchema = z.object({
status: z.number(),
}).optional();
const searchSchema = z.object({
results: z
.array(
@@ -373,7 +397,7 @@ const searchSchema = z.object({
name: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(),
mediaInfo: z.object({}).optional(),
mediaInfo: searchMediaInfoSchema,
}),
z.object({
id: z.number(),
@@ -381,14 +405,14 @@ const searchSchema = z.object({
title: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(),
mediaInfo: z.object({}).optional(),
mediaInfo: searchMediaInfoSchema,
}),
z.object({
id: z.number(),
mediaType: z.literal("person"),
name: z.string(),
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
mediaInfo: z.object({}).optional(),
mediaInfo: searchMediaInfoSchema,
}),
]),
)
@@ -131,7 +131,9 @@ export const mediaRequestsChildrenOptions = createChildrenOptions<MediaRequestCh
return [
{
key: "request",
hide: (option) => option.result.inLibrary || option.integration.permissions?.hasInteractAccess === false,
hide: (option) =>
(option.result.type === "movie" && option.result.inLibrary) ||
option.integration.permissions?.hasInteractAccess === false,
Component(option) {
const t = useScopedI18n("search.mode.media");
return (
@@ -1,4 +1,4 @@
import { Group, Image, Stack, Text } from "@mantine/core";
import { Badge, Group, Image, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconMovie, IconSearch } from "@tabler/icons-react";
import { keepPreviousData } from "@tanstack/react-query";
@@ -7,12 +7,22 @@ import { useAtomValue } from "jotai";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import type { MediaAvailability } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
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",
};
type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequests"][number];
type MediaRequestSearchOption =
@@ -47,6 +57,11 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
}
const { result } = option;
const badgeLabel = result.availability ? availabilityLabels[result.availability as MediaAvailability] : undefined;
const badgeColor = result.availability
? mediaAvailabilityConfiguration[result.availability as MediaAvailability]?.color
: undefined;
return (
<Group w="100%" wrap="nowrap" align="center" px="md" py="xs">
{result.image ? (
@@ -54,8 +69,11 @@ export const mediaRequestSearchGroup = createGroup<MediaRequestSearchOption>({
) : (
<IconSearch stroke={1.5} size={35} />
)}
<Stack gap={2}>
<Text>{result.name}</Text>
<Stack gap={2} style={{ flex: 1 }}>
<Group gap="xs" wrap="nowrap">
<Text>{result.name}</Text>
{badgeLabel && <Badge size="xs" color={badgeColor} variant="light">{badgeLabel}</Badge>}
</Group>
<Text c="dimmed" size="sm" lineClamp={2}>
{result.text ?? getIntegrationName(result.integration.kind)}
</Text>