From d4279a938d77e12dd8a203d7e9db911bc89ea0a2 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 29 May 2026 23:42:39 +0200 Subject: [PATCH 1/5] wip on fixing seerr media request --- .../new/_integration-new-form.tsx | 3 +- .../router/integration/integration-router.ts | 176 +++++++++++----- .../integration/integration-router.spec.ts | 132 +++++++++++- .../actions/items/group-action-item.tsx | 3 + .../spotlight/src/components/spotlight.tsx | 41 +++- packages/spotlight/src/index.ts | 19 +- packages/spotlight/src/lib/interaction.ts | 2 +- .../external/search-engines-search-group.tsx | 56 +++--- .../modes/home/home-search-engine-group.tsx | 74 +++++-- packages/spotlight/src/modes/index.tsx | 2 + packages/spotlight/src/modes/media/index.tsx | 8 + .../media/media-request-search-group.tsx | 150 ++++++++++++++ packages/spotlight/src/spotlight-store.ts | 8 + packages/translation/src/lang/en-gb.json | 15 +- packages/translation/src/lang/en.json | 15 +- .../src/media-requests/list/component.tsx | 72 +++++-- .../media-requests/search-button.module.css | 24 +++ .../src/media-requests/stats/component.tsx | 188 +++++++++++------- 18 files changed, 794 insertions(+), 194 deletions(-) create mode 100644 packages/spotlight/src/modes/media/index.tsx create mode 100644 packages/spotlight/src/modes/media/media-request-search-group.tsx create mode 100644 packages/widgets/src/media-requests/search-button.module.css diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index b7fbf3291..379ebef3b 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -148,7 +148,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => ); }; - const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search"); + const integrationCategories = integrationDefs[searchParams.kind].category.flat(); + const supportsSearchEngine = integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch"); return (
void handleSubmitAsync(value))}> diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 5591b74ed..4934429f2 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -1,6 +1,8 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; +import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server"; +import { constructIntegrationPermissions } from "@homarr/auth/shared"; import { createId, objectEntries } from "@homarr/common"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; @@ -14,11 +16,9 @@ import { integrations, integrationSecrets, integrationUserPermissions, - searchEngines, } from "@homarr/db/schema"; import type { IntegrationSecretKind } from "@homarr/definitions"; import { - getIconUrl, getIntegrationKindsByCategory, getPermissionsWithParents, integrationCategories, @@ -41,6 +41,7 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn import { mapTestConnectionError } from "./map-test-connection-error"; const logger = createLogger({ module: "integrationRouter" }); +const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch"); export const integrationRouter = createTRPCRouter({ all: protectedProcedure.query(async ({ ctx }) => { @@ -314,20 +315,7 @@ export const integrationRouter = createTRPCRouter({ url: input.url, }); - if ( - input.attemptSearchEngineCreation && - integrationDefs[input.kind].category.flatMap((category) => category).includes("search") - ) { - const icon = getIconUrl(input.kind); - await ctx.db.insert(searchEngines).values({ - id: createId(), - name: input.name, - integrationId, - type: "fromIntegration", - iconUrl: icon, - short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name), - }); - } + // Media request search is a first-class Spotlight action. Do not create a search engine for it. }), update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); @@ -618,8 +606,135 @@ export const integrationRouter = createTRPCRouter({ const integrationInstance = await createIntegrationAsync(ctx.integration); return await integrationInstance.searchAsync(encodeURI(input.query)); }), + mediaRequestSearchTargets: protectedProcedure.query(async ({ ctx }) => { + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx); + + return integrationsWithAccess.map((integration) => { + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }; + }); + }), + searchMediaRequests: protectedProcedure + .input( + z.object({ + query: z.string(), + integrationIds: z.array(z.string()).optional(), + }), + ) + .query(async ({ ctx, input }) => { + const query = input.query.trim(); + if (query.length === 0) return []; + + const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds); + + const results = await Promise.all( + integrationsWithAccess.map(async (integration) => { + const integrationInstance = await createIntegrationAsync({ + id: integration.id, + name: integration.name, + url: integration.url, + kind: integration.kind as (typeof mediaRequestSearchKinds)[number], + externalUrl: integration.app?.href ?? null, + decryptedSecrets: integration.secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }); + const permissions = constructIntegrationPermissions(integration, ctx.session); + + return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) => + searchResults.map((result) => ({ + ...result, + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + url: integration.url, + permissions: { + hasInteractAccess: permissions.hasInteractAccess, + }, + }, + })), + ); + }), + ); + + return results.flat(); + }), }); +interface IntegrationRouterContext { + db: Database; + session: Parameters[2]; +} + +const getAccessibleMediaRequestSearchIntegrationsAsync = async ( + ctx: IntegrationRouterContext, + integrationIds?: string[], +) => { + const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, ctx.session?.user.id ?? ""), + }); + + const integrationsFromDb = await ctx.db.query.integrations.findMany({ + where: + integrationIds && integrationIds.length > 0 + ? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds)) + : inArray(integrations.kind, mediaRequestSearchKinds), + orderBy: asc(integrations.name), + with: { + app: true, + secrets: true, + items: { + with: { + item: true, + }, + }, + userPermissions: { + where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""), + }, + groupPermissions: { + where: inArray( + integrationGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId), + ), + }, + }, + }); + + if (integrationIds && integrationsFromDb.length !== integrationIds.length) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "One or more media request search integrations were not found", + }); + } + + const integrationsWithAccess = await Promise.all( + integrationsFromDb.map(async (integration) => ({ + integration, + hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session), + })), + ); + + if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User does not have permission to query one or more media request search integrations", + }); + } + + return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration); +}; + interface UpdateSecretInput { integrationId: string; value: string; @@ -640,35 +755,6 @@ interface AddSecretInput { kind: IntegrationSecretKind; } -const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => { - const searchEngines = await db.query.searchEngines.findMany({ - columns: { - short: true, - }, - }); - - const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase()); - const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase(); - - if (!usedShortNames.includes(nameByIntegrationName)) { - return nameByIntegrationName; - } - - // 8 is max length constraint - for (let i = 2; i < 9999999; i++) { - const generatedName = `${nameByIntegrationName}${i}`; - if (usedShortNames.includes(generatedName)) { - continue; - } - - return generatedName; - } - - throw new Error( - "Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.", - ); -}; - const addSecretAsync = async (db: Database, input: AddSecretInput) => { await db.insert(integrationSecrets).values({ kind: input.kind, diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts index 17927b9c4..a7cdddfa2 100644 --- a/packages/api/src/router/test/integration/integration-router.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; import { encryptSecret } from "@homarr/common/server"; -import { apps, integrations, integrationSecrets } from "@homarr/db/schema"; +import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema"; import { createDb } from "@homarr/db/test"; import type { GroupPermissionKey } from "@homarr/definitions"; @@ -59,6 +59,127 @@ describe("all should return all integrations", () => { }); }); +describe("mediaRequestSearchTargets should return accessible media search integrations", () => { + test("with integration use access should only return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values([ + { + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }, + { + id: "2", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }, + ]); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + expect(result[0]!.kind).toBe("jellyseerr"); + }); + + test("without access should not return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(0); + }); + + test("with direct integration use access should return media request search integrations", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + await db.insert(users).values({ id: defaultUserId }); + await db.insert(integrationUserPermissions).values({ + integrationId: "1", + userId: defaultUserId, + permission: "use", + }); + + const result = await caller.mediaRequestSearchTargets(); + + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("1"); + }); +}); + +describe("searchMediaRequests should validate scoped integrations", () => { + test("with a non media search integration should throw not found", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(["integration-use-all"]), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyfin", + kind: "jellyfin", + url: "http://jellyfin.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "One or more media request search integrations were not found", + ); + }); + + test("without query access to a scoped media search integration should throw forbidden", async () => { + const db = createDb(); + const caller = integrationRouter.createCaller({ + db, + deviceType: undefined, + session: defaultSessionWithPermissions(), + }); + + await db.insert(integrations).values({ + id: "1", + name: "Jellyseerr", + kind: "jellyseerr", + url: "http://jellyseerr.local", + }); + + await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow( + "User does not have permission to query one or more media request search integrations", + ); + }); +}); + describe("byId should return an integration by id", () => { test("with full access should return an integration by id", async () => { const db = createDb(); @@ -208,7 +329,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.updatedAt).toEqual(fakeNow); }); - test("with create integration access should create a new integration when creating search engine", async () => { + test("with create integration access should not create a search engine for media request search integrations", async () => { const db = createDb(); const caller = integrationRouter.createCaller({ db, @@ -243,12 +364,7 @@ describe("create should create a new integration", () => { expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/); expect(dbSecret!.updatedAt).toEqual(fakeNow); - expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id); - expect(dbSearchEngine!.short).toBe("j"); - expect(dbSearchEngine!.name).toBe(input.name); - expect(dbSearchEngine!.iconUrl).toBe( - "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg", - ); + expect(dbSearchEngine).toBeUndefined(); }); test("with create integration access should create a new integration with new linked app", async () => { diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx index eb48e04b2..4b40ca5be 100644 --- a/packages/spotlight/src/components/actions/items/group-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx @@ -44,6 +44,9 @@ export const SpotlightGroupActionItem = setQuery(interaction.query); setTimeout(() => selectAction(0, spotlightStore)); } else if (interaction.type === "mode") { + if (interaction.query !== undefined) { + setQuery(interaction.query); + } setMode(interaction.mode); } else if (interaction.type === "children") { setChildrenOptions(interaction); diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx index 4ca086f3b..acf584615 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -1,20 +1,28 @@ "use client"; import type { Dispatch, SetStateAction } from "react"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ActionIcon, Center, Group, Kbd } from "@mantine/core"; import { Spotlight as MantineSpotlight } from "@mantine/spotlight"; import { IconSearch, IconX } from "@tabler/icons-react"; +import { useSetAtom } from "jotai"; import { hotkeys } from "@homarr/definitions"; import type { TranslationObject } from "@homarr/translation"; import { useI18n } from "@homarr/translation/client"; +import type { OpenMediaRequestSearchOptions } from "../index"; import type { inferSearchInteractionOptions } from "../lib/interaction"; import type { SearchMode } from "../lib/mode"; import { searchModes } from "../modes"; import { useHomeEmptyGroups } from "../modes/help/home-empty-groups"; -import { selectAction, spotlightStore } from "../spotlight-store"; +import { + mediaRequestSearchEvent, + mediaRequestSearchScopeAtom, + selectAction, + spotlightActions, + spotlightStore, +} from "../spotlight-store"; import { SpotlightChildrenActions } from "./actions/children-actions"; import { SpotlightActionGroups } from "./actions/groups/action-group"; @@ -49,10 +57,35 @@ const SpotlightWithActiveMode = ({ modeState, queryState, activeMode }: Spotligh const [childrenOptions, setChildrenOptions] = useState | null>(null); const t = useI18n(); const inputRef = useRef(null); + const setMediaRequestSearchScope = useSetAtom(mediaRequestSearchScopeAtom); // Works as always the same amount of hooks are executed const useGroups = "groups" in activeMode ? () => activeMode.groups : activeMode.useGroups; const groups = useGroups(); const homeEmptyGroups = useHomeEmptyGroups(); + const hasModeCharacter = activeMode.modeKey !== defaultMode && activeMode.character !== undefined; + + useEffect(() => { + const handleMediaRequestSearch = (event: Event) => { + const { integrationIds, query } = (event as CustomEvent).detail ?? {}; + setMediaRequestSearchScope({ integrationIds }); + setMode("media"); + setChildrenOptions(null); + setQuery(query ?? ""); + spotlightActions.open(); + setTimeout(() => selectAction(0, spotlightStore)); + }; + + window.addEventListener(mediaRequestSearchEvent, handleMediaRequestSearch); + return () => { + window.removeEventListener(mediaRequestSearchEvent, handleMediaRequestSearch); + }; + }, [setMediaRequestSearchScope, setMode, setQuery]); + + useEffect(() => { + if (mode !== "media") { + setMediaRequestSearchScope({}); + } + }, [mode, setMediaRequestSearchScope]); return (
- {activeMode.modeKey !== defaultMode ? {activeMode.character} : null} + {hasModeCharacter ? {activeMode.character} : null} } styles={{ diff --git a/packages/spotlight/src/index.ts b/packages/spotlight/src/index.ts index 18298a57b..3433b7125 100644 --- a/packages/spotlight/src/index.ts +++ b/packages/spotlight/src/index.ts @@ -1,9 +1,9 @@ "use client"; -import { spotlightActions } from "./spotlight-store"; +import { mediaRequestSearchEvent, spotlightActions } from "./spotlight-store"; export { Spotlight } from "./components/spotlight"; -export { openSpotlight }; +export { openSpotlight, openMediaRequestSearch }; export { SpotlightProvider, useRegisterSpotlightContextResults, @@ -11,3 +11,18 @@ export { } from "./modes/home/context"; const openSpotlight = spotlightActions.open; + +export interface OpenMediaRequestSearchOptions { + integrationIds?: string[]; + query?: string; +} + +const openMediaRequestSearch = (options: OpenMediaRequestSearchOptions = {}) => { + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(mediaRequestSearchEvent, { detail: options })); + } + + spotlightActions.open(); +}; + +export { mediaRequestSearchEvent }; diff --git a/packages/spotlight/src/lib/interaction.ts b/packages/spotlight/src/lib/interaction.ts index 060a35c51..79ca26bc7 100644 --- a/packages/spotlight/src/lib/interaction.ts +++ b/packages/spotlight/src/lib/interaction.ts @@ -12,7 +12,7 @@ const searchInteractions = [ createSearchInteraction("link").optionsType<{ href: string; newTab?: boolean }>(), createSearchInteraction("javaScript").optionsType<{ onSelect: () => MaybePromise }>(), createSearchInteraction("setQuery").optionsType<{ query: string }>(), - createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"] }>(), + createSearchInteraction("mode").optionsType<{ mode: keyof TranslationObject["search"]["mode"]; query?: string }>(), createSearchInteraction("children").optionsType<{ // eslint-disable-next-line @typescript-eslint/no-explicit-any useActions: CreateChildrenOptionsProps["useActions"]; 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 da965725b..b43b6e32c 100644 --- a/packages/spotlight/src/modes/external/search-engines-search-group.tsx +++ b/packages/spotlight/src/modes/external/search-engines-search-group.tsx @@ -64,6 +64,36 @@ type MediaRequestChildrenProps = { kind: IntegrationKind; url: string; id: string; + permissions?: { + hasInteractAccess: boolean; + }; + }; +}; + +export const useMediaRequestSearchInteraction = ( + integration: MediaRequestChildrenProps["integration"], + searchResult: FromIntegrationSearchResult, +): inferSearchInteractionDefinition<"link" | "children"> => { + const { openSearchInNewTab } = useSettings(); + + const type = searchResult.type; + if (type === "person") { + return { + type: "link", + href: searchResult.link, + newTab: openSearchInNewTab, + }; + } + + return { + type: "children", + ...mediaRequestsChildrenOptions({ + result: { + ...searchResult, + type, + }, + integration, + }), }; }; @@ -71,8 +101,6 @@ export const useFromIntegrationSearchInteraction = ( searchEngine: SearchEngine, searchResult: FromIntegrationSearchResult, ): inferSearchInteractionDefinition<"link" | "javaScript" | "children"> => { - const { openSearchInNewTab } = useSettings(); - if (searchEngine.type !== "fromIntegration") { throw new Error("Invalid search engine type"); } @@ -87,25 +115,7 @@ export const useFromIntegrationSearchInteraction = ( ) && "type" in searchResult ) { - const type = searchResult.type; - if (type === "person") { - return { - type: "link", - href: searchResult.link, - newTab: openSearchInNewTab, - }; - } - - return { - type: "children", - ...mediaRequestsChildrenOptions({ - result: { - ...searchResult, - type, - }, - integration: searchEngine.integration, - }), - }; + return useMediaRequestSearchInteraction(searchEngine.integration, searchResult); } return { @@ -115,13 +125,13 @@ export const useFromIntegrationSearchInteraction = ( }; }; -const mediaRequestsChildrenOptions = createChildrenOptions({ +export const mediaRequestsChildrenOptions = createChildrenOptions({ useActions() { const { openModal } = useModalAction(RequestMediaModal); return [ { key: "request", - hide: (option) => option.result.inLibrary, + hide: (option) => option.result.inLibrary || option.integration.permissions?.hasInteractAccess === false, Component(option) { const t = useScopedI18n("search.mode.media"); return ( 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 40e40f131..4aff71a52 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 { IconSearch, IconSearchOff } from "@tabler/icons-react"; +import { IconMovie, IconSearch, IconSearchOff } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; @@ -20,6 +20,7 @@ type GroupItem = { name: string; description?: string; icon: TablerIcon | string; + disabled?: boolean; useInteraction: (query: string) => inferSearchInteractionDefinition; }; @@ -37,7 +38,7 @@ export const homeSearchEngineGroup = createGroup({ ); return ( - + {icon} {item.name} @@ -63,6 +64,10 @@ 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( { @@ -77,9 +82,15 @@ export const homeSearchEngineGroup = createGroup({ return { isLoading: - defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading", - isError: defaultSearchEngineQuery.isError || (resultQuery.isError && fromIntegrationEnabled), - data: createDefaultSearchEntries(defaultSearchEngine, results, session, query, t), + 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), }; }, }); @@ -87,16 +98,40 @@ 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[] => { - if (!session?.user && !defaultSearchEngine) { - return []; - } + 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 (!defaultSearchEngine) { return [ + mediaRequestSearchEntry, { id: "no-default", name: t("search.mode.home.group.search.option.no-default.label"), @@ -115,6 +150,7 @@ const createDefaultSearchEntries = ( if (defaultSearchEngine.type === "generic") { return [ + mediaRequestSearchEntry, { id: "search", name: t("search.mode.home.group.search.option.search.label", { @@ -137,6 +173,7 @@ const createDefaultSearchEntries = ( if (!results) { return [ + mediaRequestSearchEntry, { id: "from-integration", name: defaultSearchEngine.name, @@ -151,13 +188,16 @@ const createDefaultSearchEntries = ( ]; } - return results.map((result) => ({ - id: `search-${result.id}`, - name: result.name, - description: result.text, - icon: result.image ?? IconSearch, - useInteraction() { - return useFromIntegrationSearchInteraction(defaultSearchEngine, result); - }, - })); + return [ + mediaRequestSearchEntry, + ...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/index.tsx b/packages/spotlight/src/modes/index.tsx index fa6e81916..0c860069f 100644 --- a/packages/spotlight/src/modes/index.tsx +++ b/packages/spotlight/src/modes/index.tsx @@ -2,6 +2,7 @@ import { appIntegrationBoardMode } from "./app-integration-board"; import { commandMode } from "./command"; import { externalMode } from "./external"; import { homeMode } from "./home"; +import { mediaMode } from "./media"; import { pageMode } from "./page"; import { userGroupMode } from "./user-group"; @@ -11,5 +12,6 @@ export const searchModes = [ externalMode, commandMode, pageMode, + mediaMode, homeMode, ] as const; diff --git a/packages/spotlight/src/modes/media/index.tsx b/packages/spotlight/src/modes/media/index.tsx new file mode 100644 index 000000000..46b7a661f --- /dev/null +++ b/packages/spotlight/src/modes/media/index.tsx @@ -0,0 +1,8 @@ +import type { SearchMode } from "../../lib/mode"; +import { mediaRequestSearchGroup } from "./media-request-search-group"; + +export const mediaMode = { + modeKey: "media", + character: undefined, + groups: [mediaRequestSearchGroup], +} satisfies SearchMode; diff --git a/packages/spotlight/src/modes/media/media-request-search-group.tsx b/packages/spotlight/src/modes/media/media-request-search-group.tsx new file mode 100644 index 000000000..56a6ba399 --- /dev/null +++ b/packages/spotlight/src/modes/media/media-request-search-group.tsx @@ -0,0 +1,150 @@ +import { 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"; +import { useAtomValue } from "jotai"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { getIntegrationName } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { createGroup } from "../../lib/group"; +import { mediaRequestSearchScopeAtom } from "../../spotlight-store"; +import { useMediaRequestSearchInteraction } from "../external/search-engines-search-group"; + +type MediaRequestSearchResult = RouterOutputs["integration"]["searchMediaRequests"][number]; + +type MediaRequestSearchOption = + | { + key: string; + kind: "disabled" | "hint"; + label: string; + description: string; + } + | { + key: string; + kind: "result"; + result: MediaRequestSearchResult; + }; + +export const mediaRequestSearchGroup = createGroup({ + keyPath: "key", + title: (t) => t("search.mode.media.group.title"), + Component(option) { + if (option.kind !== "result") { + return ( + + {option.kind === "disabled" ? : } + + {option.label} + + {option.description} + + + + ); + } + + const { result } = option; + return ( + + {result.image ? ( + + ) : ( + + )} + + {result.name} + + {result.text ?? getIntegrationName(result.integration.kind)} + + + + ); + }, + useInteraction(option) { + if (option.kind !== "result") { + return { + type: "none", + }; + } + + return useMediaRequestSearchInteraction(option.result.integration, option.result); + }, + useQueryOptions(query) { + const tMedia = useScopedI18n("search.mode.media"); + const scope = useAtomValue(mediaRequestSearchScopeAtom); + const [debouncedQuery] = useDebouncedValue(query.trim(), 150); + const targetsQuery = clientApi.integration.mediaRequestSearchTargets.useQuery(); + + const scopedTargets = (targetsQuery.data ?? []).filter((target) => + scope.integrationIds ? scope.integrationIds.includes(target.id) : true, + ); + const hasScopedTargets = scopedTargets.length > 0; + const shouldSearch = debouncedQuery.length > 0 && hasScopedTargets; + const integrationIds = scope.integrationIds ? scopedTargets.map((target) => target.id) : undefined; + + const resultsQuery = clientApi.integration.searchMediaRequests.useQuery( + { query: debouncedQuery, integrationIds }, + { + enabled: shouldSearch, + placeholderData: keepPreviousData, + select: (data) => data.slice(0, 10), + }, + ); + + const isLoading = targetsQuery.isLoading || (shouldSearch && resultsQuery.isLoading); + const isError = targetsQuery.isError || (shouldSearch && resultsQuery.isError); + + if (!targetsQuery.data) { + return { + isLoading, + isError, + data: [], + }; + } + + if (!hasScopedTargets) { + return { + isLoading, + isError, + data: [ + { + key: "disabled", + kind: "disabled", + label: tMedia("action.search.label"), + description: scope.integrationIds + ? tMedia("action.search.disabled.scoped") + : tMedia("action.search.disabled.noIntegration"), + }, + ], + }; + } + + if (debouncedQuery.length === 0) { + return { + isLoading, + isError, + data: [ + { + key: "hint", + kind: "hint", + label: tMedia("action.search.label"), + description: tMedia("action.search.description"), + }, + ], + }; + } + + return { + isLoading, + isError, + data: (resultsQuery.data ?? []).map((result) => ({ + key: `result-${result.integration.id}-${result.type}-${result.id}`, + kind: "result", + result, + })), + }; + }, +}); diff --git a/packages/spotlight/src/spotlight-store.ts b/packages/spotlight/src/spotlight-store.ts index 052ac59cb..a144cdc94 100644 --- a/packages/spotlight/src/spotlight-store.ts +++ b/packages/spotlight/src/spotlight-store.ts @@ -3,9 +3,17 @@ import { clamp } from "@mantine/hooks"; import type { SpotlightStore } from "@mantine/spotlight"; import { createSpotlight } from "@mantine/spotlight"; +import { atom } from "jotai"; export const [spotlightStore, spotlightActions] = createSpotlight(); +export interface MediaRequestSearchScope { + integrationIds?: string[]; +} + +export const mediaRequestSearchScopeAtom = atom({}); +export const mediaRequestSearchEvent = "homarr:spotlight:media-request-search"; + export const setSelectedAction = (index: number, store: SpotlightStore) => { store.updateState((state) => ({ ...state, selected: index })); }; diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index 04bc5a03c..b65771d2d 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -4553,7 +4553,20 @@ "media": { "requestMovie": "", "requestSeries": "", - "openIn": "" + "openIn": "", + "group": { + "title": "Movies and shows" + }, + "action": { + "search": { + "label": "Search for movies or shows", + "description": "Start typing to search your media request integrations", + "disabled": { + "noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.", + "scoped": "This widget does not have an accessible media request integration." + } + } + } }, "external": { "help": "", diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a62728845..9ddcec3f0 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4553,7 +4553,20 @@ "media": { "requestMovie": "Request movie", "requestSeries": "Request series", - "openIn": "Open in {kind}" + "openIn": "Open in {kind}", + "group": { + "title": "Movies and shows" + }, + "action": { + "search": { + "label": "Search for movies or shows", + "description": "Start typing to search your media request integrations", + "disabled": { + "noIntegration": "Add an Overseerr, Jellyseerr, or Seerr integration to enable media search.", + "scoped": "This widget does not have an accessible media request integration." + } + } + } }, "external": { "help": "Use an external search engine", diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 5d86c40a1..f93ccacc3 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -1,17 +1,32 @@ "use client"; -import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core"; -import { IconThumbDown, IconThumbUp } from "@tabler/icons-react"; +import { + ActionIcon, + Anchor, + Avatar, + Badge, + Box, + Card, + Group, + Image, + ScrollArea, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { IconSearch, 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 type { MediaRequestStatus } from "@homarr/integrations/types"; import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types"; +import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; +import classes from "../search-button.module.css"; export default function MediaServerWidget({ integrationIds, @@ -61,25 +76,46 @@ export default function MediaServerWidget({ if (mediaRequests.length === 0) throw new NoIntegrationDataError(); return ( - - - {mediaRequests.map((mediaRequest) => ( - - ))} - - + + {!isEditMode && } + + + {mediaRequests.map((mediaRequest) => ( + + ))} + + + ); } +const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => { + const t = useScopedI18n("search.mode.media"); + + return ( + + openMediaRequestSearch({ integrationIds })} + > + + + + ); +}; + interface MediaRequestCardProps { request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number]; isTiny: boolean; diff --git a/packages/widgets/src/media-requests/search-button.module.css b/packages/widgets/src/media-requests/search-button.module.css new file mode 100644 index 000000000..b131da24d --- /dev/null +++ b/packages/widgets/src/media-requests/search-button.module.css @@ -0,0 +1,24 @@ +.searchRoot { + position: relative; + height: 100%; +} + +.searchButton { + position: absolute; + top: var(--mantine-spacing-xs); + right: var(--mantine-spacing-xs); + z-index: 2; + opacity: 0; + transition: opacity 120ms ease; +} + +.searchRoot:hover .searchButton, +.searchRoot:focus-within .searchButton { + opacity: 1; +} + +@media (hover: none) { + .searchButton { + opacity: 1; + } +} diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 4a1b6ed09..5f5bddbf7 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Avatar, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { ActionIcon, Avatar, Box, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core"; import type { Icon } from "@tabler/icons-react"; import { IconDeviceTv, @@ -9,6 +9,7 @@ import { IconMovie, IconPlayerPlay, IconReceipt, + IconSearch, IconThumbDown, IconThumbUp, } from "@tabler/icons-react"; @@ -17,11 +18,13 @@ import combineClasses from "clsx"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import type { RequestStats } from "@homarr/integrations/types"; +import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "./component.module.css"; +import searchClasses from "../search-button.module.css"; const OVERSEERR_COLOR = "#ECB000"; const JELLYSEERR_COLOR = "#6677CC"; @@ -93,83 +96,122 @@ export default function MediaServerWidget({ const isTiny = width < 256; return ( - - - - {t("titles.stats.main")} - - - {data.map((stat) => ( - - - - - - - {stat.number} + + {!isEditMode && } + + + + {t("titles.stats.main")} + + + {data.map((stat) => ( + + + + + + + {stat.number} + + + + + + ))} + + + + + {t("titles.users.main")} ({t("titles.users.requests")}) + + + {requestStats.users.slice(0, 10).map((user) => ( + + + + + + + + {user.displayName} - - - - ))} - - - - - {t("titles.users.main")} ({t("titles.users.requests")}) - - - {requestStats.users.slice(0, 10).map((user) => ( - - - - - - - - {user.displayName} + + + {user.requestCount} - - - {user.requestCount} - - - - ))} + + ))} + - + ); } + +const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }) => { + const t = useScopedI18n("search.mode.media"); + + return ( + + openMediaRequestSearch({ integrationIds })} + > + + + + ); +}; From 6230db53e0daad5fb6826930498264c0b3045c3a Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Fri, 29 May 2026 21:43:47 +0000 Subject: [PATCH 2/5] style: auto-fix lint and format --- .../new/_integration-new-form.tsx | 3 ++- .../router/integration/integration-router.ts | 12 +++++----- .../src/media-requests/list/component.tsx | 2 +- .../src/media-requests/stats/component.tsx | 24 +++---------------- 4 files changed, 12 insertions(+), 29 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index 379ebef3b..2f610328a 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -149,7 +149,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => }; const integrationCategories = integrationDefs[searchParams.kind].category.flat(); - const supportsSearchEngine = integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch"); + const supportsSearchEngine = + integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch"); return ( void handleSubmitAsync(value))}> diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 4934429f2..a711bb3a5 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -81,7 +81,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -129,7 +129,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -180,7 +180,7 @@ export const integrationRouter = createTRPCRouter({ }, }; }) - .sort( + .toSorted( (integrationA, integrationB) => integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind), ); @@ -483,7 +483,7 @@ export const integrationRouter = createTRPCRouter({ }); return { - inherited: dbGroupPermissions.sort((permissionA, permissionB) => { + inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), users: userPermissions @@ -491,7 +491,7 @@ export const integrationRouter = createTRPCRouter({ user, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? ""); }), groups: dbGroupIntegrationPermission @@ -502,7 +502,7 @@ export const integrationRouter = createTRPCRouter({ }, permission, })) - .sort((permissionA, permissionB) => { + .toSorted((permissionA, permissionB) => { return permissionA.group.name.localeCompare(permissionB.group.name); }), }; diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index f93ccacc3..3a17c5e0f 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -58,7 +58,7 @@ export default function MediaServerWidget({ const newData = filteredData.concat( data.requests.map((request) => ({ ...request, integrationId: data.integrationId })), ); - return newData.sort((dataA, dataB) => { + return newData.toSorted((dataA, dataB) => { if (dataA.status === dataB.status) { return dataB.createdAt.getTime() - dataA.createdAt.getTime(); } diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 5f5bddbf7..2960d7ae6 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -120,13 +120,7 @@ export default function MediaServerWidget({ > - + {stat.number} @@ -142,13 +136,7 @@ export default function MediaServerWidget({ {t("titles.users.main")} ({t("titles.users.requests")}) - + {requestStats.users.slice(0, 10).map((user) => ( - + Date: Sat, 30 May 2026 00:58:10 +0200 Subject: [PATCH 3/5] fix: media request search bugs and availability indicators (#5824) --- .../router/integration/integration-router.ts | 15 +++++ .../search-engine/search-engine-router.ts | 17 ------ .../media-requests/media-request-types.ts | 2 + .../src/overseerr/overseerr-integration.ts | 32 ++++++++-- .../search-engines/request-media-modal.tsx | 23 +++++-- .../external/search-engines-search-group.tsx | 4 +- .../src/modes/help/home-empty-groups.tsx | 7 ++- .../modes/home/home-search-engine-group.tsx | 61 ++++--------------- .../media/media-request-search-group.tsx | 24 +++++++- packages/translation/src/lang/en-gb.json | 7 ++- packages/translation/src/lang/en.json | 7 ++- 11 files changed, 114 insertions(+), 85 deletions(-) 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": { From 6bf3c75f9fbe83eb96f8ac16c9d6977c33ff5a75 Mon Sep 17 00:00:00 2001 From: ajnart <49837342+ajnart@users.noreply.github.com> Date: Fri, 29 May 2026 22:58:49 +0000 Subject: [PATCH 4/5] style: auto-fix lint and format --- .../src/overseerr/overseerr-integration.ts | 34 ++++++++++++------- .../modes/home/home-search-engine-group.tsx | 8 ++--- .../media/media-request-search-group.tsx | 6 +++- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index eaab2a421..643d2ac60 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -75,9 +75,7 @@ export class OverseerrIntegration }); const data = await mediaInformationSchema.parseAsync(await response.json()); const requestedSeasons = [ - ...new Set( - data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? [], - ), + ...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []), ]; const { mediaInfo: _strip, ...rest } = data; return { ...rest, requestedSeasons }; @@ -351,13 +349,21 @@ interface MovieInformation { releaseDate: string; } -const mediaInfoRequestsSchema = z.object({ - requests: z.array(z.object({ - seasons: z.array(z.object({ - seasonNumber: z.number(), - })), - })).optional(), -}).optional(); +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({ @@ -383,9 +389,11 @@ const mediaInformationSchema = z.union([ }), ]); -const searchMediaInfoSchema = z.object({ - status: z.number(), -}).optional(); +const searchMediaInfoSchema = z + .object({ + status: z.number(), + }) + .optional(); const searchSchema = z.object({ results: z 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 0bd304e23..799d2216d 100644 --- a/packages/spotlight/src/modes/home/home-search-engine-group.tsx +++ b/packages/spotlight/src/modes/home/home-search-engine-group.tsx @@ -78,12 +78,8 @@ export const homeSearchEngineGroup = createGroup({ return { isLoading: - defaultSearchEngineQuery.isLoading || - (resultQuery.isLoading && fromIntegrationEnabled) || - status === "loading", - isError: - defaultSearchEngineQuery.isError || - (resultQuery.isError && fromIntegrationEnabled), + defaultSearchEngineQuery.isLoading || (resultQuery.isLoading && fromIntegrationEnabled) || status === "loading", + isError: defaultSearchEngineQuery.isError || (resultQuery.isError && fromIntegrationEnabled), data: createDefaultSearchEntries(defaultSearchEngine, results, session, query, t), }; }, 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 fc27792be..36a83f202 100644 --- a/packages/spotlight/src/modes/media/media-request-search-group.tsx +++ b/packages/spotlight/src/modes/media/media-request-search-group.tsx @@ -72,7 +72,11 @@ export const mediaRequestSearchGroup = createGroup({ {result.name} - {badgeLabel && {badgeLabel}} + {badgeLabel && ( + + {badgeLabel} + + )} {result.text ?? getIntegrationName(result.integration.kind)} From 2bfe2caffc5be94b3a608f53e996aa8b91ff361f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 07:28:36 +0000 Subject: [PATCH 5/5] fix: use i18n for availability badge labels and make requestedSeasons optional --- .../media-requests/media-request-types.ts | 4 ++-- .../modes/media/media-request-search-group.tsx | 18 ++++++++++-------- packages/translation/src/lang/en.json | 7 +++++++ 3 files changed, 19 insertions(+), 10 deletions(-) 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 e72f692a7..d475764bf 100644 --- a/packages/integrations/src/interfaces/media-requests/media-request-types.ts +++ b/packages/integrations/src/interfaces/media-requests/media-request-types.ts @@ -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; 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 36a83f202..3b4f42168 100644 --- a/packages/spotlight/src/modes/media/media-request-search-group.tsx +++ b/packages/spotlight/src/modes/media/media-request-search-group.tsx @@ -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> = { - 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({ keyPath: "key", title: (t) => t("search.mode.media.group.title"), Component(option) { + const tMedia = useScopedI18n("search.mode.media"); + if (option.kind !== "result") { return ( @@ -57,7 +54,12 @@ export const mediaRequestSearchGroup = createGroup({ } 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; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index db311c642..5bae7cbd7 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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": {