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 })} + > + + + + ); +};