diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index a711bb3a5..4375644cc 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -33,6 +33,7 @@ import { integrationSavePermissionsSchema, integrationUpdateSchema, } from "@homarr/validation/integration"; +import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; @@ -670,6 +671,20 @@ export const integrationRouter = createTRPCRouter({ return results.flat(); }), + getMediaRequestOptions: protectedProcedure + .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestOptionsSchema) + .query(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); + }), + requestMedia: protectedProcedure + .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr")) + .input(mediaRequestRequestSchema) + .mutation(async ({ ctx, input }) => { + const integration = await createIntegrationAsync(ctx.integration); + return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); + }), }); interface IntegrationRouterContext { diff --git a/packages/api/src/router/search-engine/search-engine-router.ts b/packages/api/src/router/search-engine/search-engine-router.ts index ddfc892a9..8c72baf6e 100644 --- a/packages/api/src/router/search-engine/search-engine-router.ts +++ b/packages/api/src/router/search-engine/search-engine-router.ts @@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { asc, eq, like } from "@homarr/db"; import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries"; import { searchEngines, users } from "@homarr/db/schema"; -import { createIntegrationAsync } from "@homarr/integrations"; import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common"; import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine"; -import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "searchEngineRouter" }); @@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({ limit: input.limit, }); }), - getMediaRequestOptions: protectedProcedure - .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr")) - .input(mediaRequestOptionsSchema) - .query(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); - }), - requestMedia: protectedProcedure - .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr")) - .input(mediaRequestRequestSchema) - .mutation(async ({ ctx, input }) => { - const integration = await createIntegrationAsync(ctx.integration); - return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); - }), create: permissionRequiredProcedure .requiresPermission("search-engine-create") .input(searchEngineManageSchema) 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 4d7b094ff..e72f692a7 100644 --- a/packages/integrations/src/interfaces/media-requests/media-request-types.ts +++ b/packages/integrations/src/interfaces/media-requests/media-request-types.ts @@ -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; diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index 619af2541..eaab2a421 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -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, }), ]), ) diff --git a/packages/modals-collection/src/search-engines/request-media-modal.tsx b/packages/modals-collection/src/search-engines/request-media-modal.tsx index 89306f3af..c75647969 100644 --- a/packages/modals-collection/src/search-engines/request-media-modal.tsx +++ b/packages/modals-collection/src/search-engines/request-media-modal.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core"; -import type { MRT_ColumnDef } from "mantine-react-table"; +import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core"; +import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table"; import { MRT_Table } from "mantine-react-table"; import { clientApi } from "@homarr/api/client"; @@ -16,13 +16,13 @@ interface RequestMediaModalProps { } export const RequestMediaModal = createModal(({ actions, innerProps }) => { - const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({ + const { data, isPending: isPendingQuery } = clientApi.integration.getMediaRequestOptions.useQuery({ integrationId: innerProps.integrationId, mediaId: innerProps.mediaId, mediaType: innerProps.mediaType, }); - const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({ + const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({ onSuccess() { actions.closeModal(); showSuccessNotification({ @@ -33,6 +33,7 @@ export const RequestMediaModal = createModal(({ actions, const isPending = isPendingQuery || isPendingMutation; const t = useI18n(); + const requestedSeasons = new Set(data?.requestedSeasons ?? []); const columns = useMemo[]>( () => [ @@ -44,8 +45,18 @@ export const RequestMediaModal = createModal(({ actions, accessorKey: "episodeCount", header: t("search.engine.media.request.modal.table.header.episodes"), }, + { + id: "status", + header: t("search.engine.media.request.modal.table.header.status"), + Cell: ({ row }) => + requestedSeasons.has(row.original.seasonNumber) ? ( + + {t("search.engine.media.request.modal.table.status.requested")} + + ) : null, + }, ], - [], + [data?.requestedSeasons], ); const table = useTranslatedMantineReactTable({ @@ -56,7 +67,7 @@ export const RequestMediaModal = createModal(({ actions, enablePagination: false, enableSorting: false, enableSelectAll: true, - enableRowSelection: true, + enableRowSelection: (row: MRT_Row) => !requestedSeasons.has(row.original.seasonNumber), mantineTableProps: { highlightOnHover: false, striped: "odd", diff --git a/packages/spotlight/src/modes/external/search-engines-search-group.tsx b/packages/spotlight/src/modes/external/search-engines-search-group.tsx index b43b6e32c..34890b159 100644 --- a/packages/spotlight/src/modes/external/search-engines-search-group.tsx +++ b/packages/spotlight/src/modes/external/search-engines-search-group.tsx @@ -131,7 +131,9 @@ export const mediaRequestsChildrenOptions = createChildrenOptions 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 ( diff --git a/packages/spotlight/src/modes/help/home-empty-groups.tsx b/packages/spotlight/src/modes/help/home-empty-groups.tsx index e035e66b9..28933696b 100644 --- a/packages/spotlight/src/modes/help/home-empty-groups.tsx +++ b/packages/spotlight/src/modes/help/home-empty-groups.tsx @@ -19,6 +19,7 @@ import type { SearchMode } from "../../lib/mode"; import { appIntegrationBoardMode } from "../app-integration-board"; import { commandMode } from "../command"; import { externalMode } from "../external"; +import { mediaMode } from "../media"; import { pageMode } from "../page"; import { userGroupMode } from "../user-group"; @@ -33,7 +34,7 @@ type QuickLinkOption = { export const useHomeEmptyGroups = () => { const { data: session } = useSession(); const tPages = useScopedI18n("search.mode.page.group.page.option"); - const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, commandMode, pageMode]; + const visibleSearchModes: SearchMode[] = [appIntegrationBoardMode, externalMode, mediaMode, commandMode, pageMode]; if (session?.user.permissions.includes("admin")) { visibleSearchModes.unshift(userGroupMode); @@ -87,7 +88,7 @@ export const useHomeEmptyGroups = () => { useInteraction: interaction.link(({ path }) => ({ href: path })), }), createGroup({ - keyPath: "character", + keyPath: "modeKey", title: (t) => t("search.mode.help.group.mode.title"), options: visibleSearchModes.map(({ character, modeKey }) => ({ character, modeKey })), Component: ({ modeKey, character }) => { @@ -96,7 +97,7 @@ export const useHomeEmptyGroups = () => { return ( {t("help")} - {character} + {character && {character}} ); }, diff --git a/packages/spotlight/src/modes/home/home-search-engine-group.tsx b/packages/spotlight/src/modes/home/home-search-engine-group.tsx index 4aff71a52..0bd304e23 100644 --- a/packages/spotlight/src/modes/home/home-search-engine-group.tsx +++ b/packages/spotlight/src/modes/home/home-search-engine-group.tsx @@ -1,5 +1,5 @@ import { Box, Group, Stack, Text } from "@mantine/core"; -import { IconMovie, IconSearch, IconSearchOff } from "@tabler/icons-react"; +import { IconSearch, IconSearchOff } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; @@ -64,10 +64,6 @@ export const homeSearchEngineGroup = createGroup({ clientApi.searchEngine.getDefaultSearchEngine.useQuery(undefined, { enabled: status !== "loading", }); - const { data: mediaRequestSearchTargets, ...mediaRequestSearchTargetsQuery } = - clientApi.integration.mediaRequestSearchTargets.useQuery(undefined, { - enabled: status !== "loading" && session?.user !== undefined, - }); const fromIntegrationEnabled = defaultSearchEngine?.type === "fromIntegration" && query.length > 0; const { data: results, ...resultQuery } = clientApi.integration.searchInIntegration.useQuery( { @@ -83,14 +79,12 @@ export const homeSearchEngineGroup = createGroup({ return { isLoading: defaultSearchEngineQuery.isLoading || - mediaRequestSearchTargetsQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading", isError: defaultSearchEngineQuery.isError || - mediaRequestSearchTargetsQuery.isError || (resultQuery.isError && fromIntegrationEnabled), - data: createDefaultSearchEntries(defaultSearchEngine, results, mediaRequestSearchTargets, session, query, t), + data: createDefaultSearchEntries(defaultSearchEngine, results, session, query, t), }; }, }); @@ -98,40 +92,14 @@ export const homeSearchEngineGroup = createGroup({ const createDefaultSearchEntries = ( defaultSearchEngine: RouterOutputs["searchEngine"]["getDefaultSearchEngine"] | null, results: RouterOutputs["integration"]["searchInIntegration"] | undefined, - mediaRequestSearchTargets: RouterOutputs["integration"]["mediaRequestSearchTargets"] | undefined, session: Session | null, query: string, t: TranslationFunction, ): GroupItem[] => { - const mediaRequestSearchEntry: GroupItem = { - id: "media-request-search", - name: t("search.mode.media.action.search.label"), - description: - mediaRequestSearchTargets && mediaRequestSearchTargets.length === 0 - ? t("search.mode.media.action.search.disabled.noIntegration") - : t("search.mode.media.action.search.description"), - icon: IconMovie, - disabled: !mediaRequestSearchTargets || mediaRequestSearchTargets.length === 0, - useInteraction(query) { - if (!mediaRequestSearchTargets || mediaRequestSearchTargets.length === 0) { - return { - type: "none", - }; - } - - return { - type: "mode", - mode: "media", - query, - }; - }, - }; - - if (!session?.user && !defaultSearchEngine) return [mediaRequestSearchEntry]; + if (!session?.user && !defaultSearchEngine) return []; if (!defaultSearchEngine) { return [ - mediaRequestSearchEntry, { id: "no-default", name: t("search.mode.home.group.search.option.no-default.label"), @@ -150,7 +118,6 @@ const createDefaultSearchEntries = ( if (defaultSearchEngine.type === "generic") { return [ - mediaRequestSearchEntry, { id: "search", name: t("search.mode.home.group.search.option.search.label", { @@ -173,7 +140,6 @@ const createDefaultSearchEntries = ( if (!results) { return [ - mediaRequestSearchEntry, { id: "from-integration", name: defaultSearchEngine.name, @@ -188,16 +154,13 @@ const createDefaultSearchEntries = ( ]; } - return [ - mediaRequestSearchEntry, - ...results.map((result) => ({ - id: `search-${result.id}`, - name: result.name, - description: result.text, - icon: result.image ?? IconSearch, - useInteraction() { - return useFromIntegrationSearchInteraction(defaultSearchEngine, result); - }, - })), - ]; + return results.map((result) => ({ + id: `search-${result.id}`, + name: result.name, + description: result.text, + icon: result.image ?? IconSearch, + useInteraction() { + return useFromIntegrationSearchInteraction(defaultSearchEngine, result); + }, + })); }; diff --git a/packages/spotlight/src/modes/media/media-request-search-group.tsx b/packages/spotlight/src/modes/media/media-request-search-group.tsx index 56a6ba399..fc27792be 100644 --- a/packages/spotlight/src/modes/media/media-request-search-group.tsx +++ b/packages/spotlight/src/modes/media/media-request-search-group.tsx @@ -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> = { + 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({ } 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 ( {result.image ? ( @@ -54,8 +69,11 @@ export const mediaRequestSearchGroup = createGroup({ ) : ( )} - - {result.name} + + + {result.name} + {badgeLabel && {badgeLabel}} + {result.text ?? getIntegrationName(result.integration.kind)} diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index b65771d2d..dd73c5091 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -4551,6 +4551,7 @@ } }, "media": { + "help": "Search for movies or shows to request", "requestMovie": "", "requestSeries": "", "openIn": "", @@ -4842,7 +4843,11 @@ "table": { "header": { "season": "Season", - "episodes": "Episodes" + "episodes": "Episodes", + "status": "Status" + }, + "status": { + "requested": "Requested" } }, "button": { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 9ddcec3f0..db311c642 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4551,6 +4551,7 @@ } }, "media": { + "help": "Search for movies or shows to request", "requestMovie": "Request movie", "requestSeries": "Request series", "openIn": "Open in {kind}", @@ -4842,7 +4843,11 @@ "table": { "header": { "season": "Season", - "episodes": "Episodes" + "episodes": "Episodes", + "status": "Status" + }, + "status": { + "requested": "Requested" } }, "button": {