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 8e8fe853a..0d48ab226 100644 --- a/packages/api/src/router/search-engine/search-engine-router.ts +++ b/packages/api/src/router/search-engine/search-engine-router.ts @@ -2,8 +2,10 @@ import { TRPCError } from "@trpc/server"; import { createId, eq, like, sql } from "@homarr/db"; import { searchEngines } from "@homarr/db/schema"; +import { integrationCreator } from "@homarr/integrations"; import { validation } from "@homarr/validation"; +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc"; export const searchEngineRouter = createTRPCRouter({ @@ -56,9 +58,32 @@ export const searchEngineRouter = createTRPCRouter({ search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => { return await ctx.db.query.searchEngines.findMany({ where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`), + with: { + integration: { + columns: { + kind: true, + url: true, + id: true, + }, + }, + }, limit: input.limit, }); }), + getMediaRequestOptions: protectedProcedure + .unstable_concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr")) + .input(validation.common.mediaRequestOptions) + .query(async ({ ctx, input }) => { + const integration = integrationCreator(ctx.integration); + return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId); + }), + requestMedia: protectedProcedure + .unstable_concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr")) + .input(validation.common.requestMedia) + .mutation(async ({ ctx, input }) => { + const integration = integrationCreator(ctx.integration); + return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); + }), create: permissionRequiredProcedure .requiresPermission("search-engine-create") .input(validation.searchEngine.manage) diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts index 31aa7c4c6..1ef500ee0 100644 --- a/packages/definitions/src/docs/homarr-docs-sitemap.ts +++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts @@ -90,6 +90,7 @@ export type HomarrDocumentationPath = | "/docs/tags/lists" | "/docs/tags/management" | "/docs/tags/media" + | "/docs/tags/minecraft" | "/docs/tags/monitoring" | "/docs/tags/news" | "/docs/tags/notebook" @@ -190,6 +191,7 @@ export type HomarrDocumentationPath = | "/docs/widgets/indexer-manager" | "/docs/widgets/media-requests" | "/docs/widgets/media-server" + | "/docs/widgets/minecraft-server-status" | "/docs/widgets/notebook" | "/docs/widgets/rss" | "/docs/widgets/video" diff --git a/packages/integrations/src/base/searchable-integration.ts b/packages/integrations/src/base/searchable-integration.ts index 918c3b07f..2b92fe4e7 100644 --- a/packages/integrations/src/base/searchable-integration.ts +++ b/packages/integrations/src/base/searchable-integration.ts @@ -1,3 +1,3 @@ -export interface ISearchableIntegration { - searchAsync(query: string): Promise<{ image?: string; name: string; link: string }[]>; +export interface ISearchableIntegration { + searchAsync(query: string): Promise; } diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index 18101e778..c4f31420f 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -6,11 +6,20 @@ import type { ISearchableIntegration } from "../base/searchable-integration"; import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request"; import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request"; +interface OverseerrSearchResult { + id: number; + name: string; + link: string; + image?: string; + text?: string; + type: Exclude["results"], undefined>[number]["mediaType"]; +} + /** * Overseerr Integration. See https://api-docs.overseerr.dev */ -export class OverseerrIntegration extends Integration implements ISearchableIntegration { - public async searchAsync(query: string): Promise<{ image?: string; name: string; link: string; text?: string }[]> { +export class OverseerrIntegration extends Integration implements ISearchableIntegration { + public async searchAsync(query: string) { const response = await fetch(this.url("/api/v1/search", { query }), { headers: { "X-Api-Key": this.getSecretValue("apiKey"), @@ -23,13 +32,53 @@ export class OverseerrIntegration extends Integration implements ISearchableInte } return schemaData.results.map((result) => ({ + id: result.id, name: "name" in result ? result.name : result.title, link: this.url(`/${result.mediaType}/${result.id}`).toString(), image: constructSearchResultImage(result), text: "overview" in result ? result.overview : undefined, + type: result.mediaType, + inLibrary: result.mediaInfo !== undefined, })); } + public async getSeriesInformationAsync(mediaType: "movie" | "tv", id: number) { + const url = mediaType === "tv" ? this.url(`/api/v1/tv/${id}`) : this.url(`/api/v1/movie/${id}`); + const response = await fetch(url, { + headers: { + "X-Api-Key": this.getSecretValue("apiKey"), + }, + }); + return await mediaInformationSchema.parseAsync(await response.json()); + } + + /** + * Request a media. See https://api-docs.overseerr.dev/#/request/post_request + * @param mediaType The media type to request. Can be "movie" or "tv". + * @param id The Overseerr ID of the media to request. + * @param seasons A list of the seasons that should be requested. + */ + public async requestMediaAsync(mediaType: "movie" | "tv", id: number, seasons?: number[]): Promise { + const url = this.url("/api/v1/request"); + const response = await fetch(url, { + method: "POST", + body: JSON.stringify({ + mediaType, + mediaId: id, + seasons, + }), + headers: { + "X-Api-Key": this.getSecretValue("apiKey"), + "Content-Type": "application/json", + }, + }); + if (response.status !== 201) { + throw new Error( + `Status code ${response.status} does not match the expected status code. The request was likely not created. Response: ${await response.text()}`, + ); + } + } + public async testConnectionAsync(): Promise { const response = await fetch(this.url("/api/v1/auth/me"), { headers: { @@ -220,6 +269,27 @@ interface MovieInformation { releaseDate: string; } +const mediaInformationSchema = z.union([ + z.object({ + id: z.number(), + overview: z.string(), + seasons: z.array( + z.object({ + id: z.number(), + name: z.string().min(0), + episodeCount: z.number().min(0), + }), + ), + numberOfSeasons: z.number(), + posterPath: z.string().startsWith("/"), + }), + z.object({ + id: z.number(), + overview: z.string(), + posterPath: z.string().startsWith("/"), + }), +]); + const searchSchema = z.object({ results: z .array( @@ -230,6 +300,7 @@ const searchSchema = z.object({ name: z.string(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), overview: z.string(), + mediaInfo: z.object({}).optional(), }), z.object({ id: z.number(), @@ -237,12 +308,14 @@ const searchSchema = z.object({ title: z.string(), posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(), overview: z.string(), + mediaInfo: z.object({}).optional(), }), z.object({ id: z.number(), mediaType: z.literal("person"), name: z.string(), profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(), + mediaInfo: z.object({}).optional(), }), ]), ) diff --git a/packages/modals-collection/src/index.ts b/packages/modals-collection/src/index.ts index 45caf0411..66672e577 100644 --- a/packages/modals-collection/src/index.ts +++ b/packages/modals-collection/src/index.ts @@ -1,3 +1,4 @@ export * from "./boards"; export * from "./invites"; export * from "./groups"; +export * from "./search-engines"; diff --git a/packages/modals-collection/src/search-engines/index.ts b/packages/modals-collection/src/search-engines/index.ts new file mode 100644 index 000000000..0ee3d7051 --- /dev/null +++ b/packages/modals-collection/src/search-engines/index.ts @@ -0,0 +1 @@ +export { RequestMediaModal } from "./request-media-modal"; diff --git a/packages/modals-collection/src/search-engines/request-media-modal.tsx b/packages/modals-collection/src/search-engines/request-media-modal.tsx new file mode 100644 index 000000000..85678e44c --- /dev/null +++ b/packages/modals-collection/src/search-engines/request-media-modal.tsx @@ -0,0 +1,119 @@ +import { useMemo } from "react"; +import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core"; +import type { MRT_ColumnDef } from "mantine-react-table"; +import { MRT_Table } from "mantine-react-table"; + +import { clientApi } from "@homarr/api/client"; +import { createModal } from "@homarr/modals"; +import { showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; + +interface RequestMediaModalProps { + integrationId: string; + mediaId: number; + mediaType: "movie" | "tv"; +} + +export const RequestMediaModal = createModal(({ actions, innerProps }) => { + const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({ + integrationId: innerProps.integrationId, + mediaId: innerProps.mediaId, + mediaType: innerProps.mediaType, + }); + + const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({ + onSuccess() { + actions.closeModal(); + showSuccessNotification({ + message: t("common.notification.create.success"), + }); + }, + }); + + const isPending = isPendingQuery || isPendingMutation; + const t = useI18n(); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: t("search.engine.media.request.modal.table.header.season"), + }, + { + accessorKey: "episodeCount", + header: t("search.engine.media.request.modal.table.header.episodes"), + }, + ], + [], + ); + + const table = useTranslatedMantineReactTable({ + columns, + data: data && "seasons" in data ? data.seasons : [], + enableColumnActions: false, + enableColumnFilters: false, + enablePagination: false, + enableSorting: false, + enableSelectAll: true, + enableRowSelection: true, + mantineTableProps: { + highlightOnHover: false, + striped: "odd", + withColumnBorders: true, + withRowBorders: true, + withTableBorder: true, + }, + initialState: { + density: "xs", + }, + }); + + const anySelected = Object.keys(table.getState().rowSelection).length > 0; + + const handleMutate = () => { + const selectedSeasons = table.getSelectedRowModel().rows.flatMap((row) => row.original.id); + mutate({ + integrationId: innerProps.integrationId, + mediaId: innerProps.mediaId, + mediaType: innerProps.mediaType, + seasons: selectedSeasons, + }); + }; + + return ( + + + {data && ( + + poster + + {data.overview} + + + )} + {innerProps.mediaType === "tv" && } + + + + + + ); +}).withOptions({ + size: "xl", +}); + +interface Season { + id: number; + name: string; + episodeCount: number; +} diff --git a/packages/spotlight/src/components/actions/children-actions.tsx b/packages/spotlight/src/components/actions/children-actions.tsx index b7f36909d..1907792a3 100644 --- a/packages/spotlight/src/components/actions/children-actions.tsx +++ b/packages/spotlight/src/components/actions/children-actions.tsx @@ -4,14 +4,25 @@ import { ChildrenActionItem } from "./items/children-action-item"; interface SpotlightChildrenActionsProps { childrenOptions: inferSearchInteractionOptions<"children">; query: string; + setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; } -export const SpotlightChildrenActions = ({ childrenOptions, query }: SpotlightChildrenActionsProps) => { +export const SpotlightChildrenActions = ({ + childrenOptions, + query, + setChildrenOptions, +}: SpotlightChildrenActionsProps) => { const actions = childrenOptions.useActions(childrenOptions.option, query); return actions .filter((action) => (typeof action.hide === "function" ? !action.hide(childrenOptions.option) : !action.hide)) .map((action) => ( - + )); }; diff --git a/packages/spotlight/src/components/actions/items/children-action-item.tsx b/packages/spotlight/src/components/actions/items/children-action-item.tsx index 0b070b147..777446b78 100644 --- a/packages/spotlight/src/components/actions/items/children-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/children-action-item.tsx @@ -8,9 +8,10 @@ interface ChildrenActionItemProps { childrenOptions: inferSearchInteractionOptions<"children">; query: string; action: ReturnType["useActions"]>[number]; + setChildrenOptions: (options: inferSearchInteractionOptions<"children">) => void; } -export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenActionItemProps) => { +export const ChildrenActionItem = ({ childrenOptions, action, query, setChildrenOptions }: ChildrenActionItemProps) => { const interaction = action.useInteraction(childrenOptions.option, query); const renderRoot = @@ -20,10 +21,20 @@ export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenA } : undefined; - const onClick = interaction.type === "javaScript" ? interaction.onSelect : undefined; + const onClick = + interaction.type === "javaScript" + ? interaction.onSelect + : interaction.type === "children" + ? () => setChildrenOptions(interaction) + : undefined; return ( - + ); diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx index 1ab051bb7..70c0da866 100644 --- a/packages/spotlight/src/components/spotlight.tsx +++ b/packages/spotlight/src/components/spotlight.tsx @@ -140,7 +140,11 @@ const SpotlightWithActiveMode = ({ modeState, activeMode, defaultMode }: Spotlig {childrenOptions ? ( - + ) : ( { diff --git a/packages/spotlight/src/lib/children.ts b/packages/spotlight/src/lib/children.ts index 0c2c34992..f7f8bdf83 100644 --- a/packages/spotlight/src/lib/children.ts +++ b/packages/spotlight/src/lib/children.ts @@ -10,7 +10,10 @@ export interface CreateChildrenOptionsProps> { key: string; Component: (option: TParentOptions) => JSX.Element; - useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">; + useInteraction: ( + option: TParentOptions, + query: string, + ) => inferSearchInteractionDefinition<"link" | "javaScript" | "children">; hide?: boolean | ((option: TParentOptions) => boolean); } 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 5c6529624..2d73ba2e9 100644 --- a/packages/spotlight/src/modes/external/search-engines-search-group.tsx +++ b/packages/spotlight/src/modes/external/search-engines-search-group.tsx @@ -1,8 +1,12 @@ import { Group, Image, Kbd, Stack, Text } from "@mantine/core"; -import { IconSearch } from "@tabler/icons-react"; +import { IconDownload, IconSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import type { IntegrationKind } from "@homarr/definitions"; +import { getIntegrationKindsByCategory, getIntegrationName } from "@homarr/definitions"; +import { useModalAction } from "@homarr/modals"; +import { RequestMediaModal } from "@homarr/modals-collection"; import { useScopedI18n } from "@homarr/translation/client"; import { createChildrenOptions } from "../../lib/children"; @@ -11,6 +15,100 @@ import { interaction } from "../../lib/interaction"; type SearchEngine = RouterOutputs["searchEngine"]["search"][number]; +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type MediaRequestChildrenProps = { + result: { + id: number; + image?: string; + name: string; + link: string; + text?: string; + type: "tv" | "movie"; + inLibrary: boolean; + }; + integration: { + kind: IntegrationKind; + url: string; + id: string; + }; +}; + +const mediaRequestsChildrenOptions = createChildrenOptions({ + useActions() { + const { openModal } = useModalAction(RequestMediaModal); + return [ + { + key: "request", + hide: (option) => option.result.inLibrary, + Component(option) { + const t = useScopedI18n("search.mode.media"); + return ( + + + {option.result.type === "tv" && {t("requestSeries")}} + {option.result.type === "movie" && {t("requestMovie")}} + + ); + }, + useInteraction: interaction.javaScript((option) => ({ + onSelect() { + openModal( + { + integrationId: option.integration.id, + mediaId: option.result.id, + mediaType: option.result.type, + }, + { + title(t) { + return t("search.engine.media.request.modal.title", { name: option.result.name }); + }, + }, + ); + }, + })), + }, + { + key: "open", + Component({ integration }) { + const tChildren = useScopedI18n("search.mode.media"); + return ( + + + {tChildren("openIn", { kind: getIntegrationName(integration.kind) })} + + ); + }, + useInteraction({ result }) { + return { + type: "link", + href: result.link, + newTab: true, + }; + }, + }, + ]; + }, + DetailComponent({ options }) { + return ( + + {options.result.image ? ( + + ) : ( + + )} + + {options.result.name} + {options.result.text && ( + + {options.result.text} + + )} + + + ); + }, +}); + export const searchEnginesChildrenOptions = createChildrenOptions({ useActions: (searchEngine, query) => { const { data } = clientApi.integration.searchInIntegration.useQuery( @@ -64,10 +162,48 @@ export const searchEnginesChildrenOptions = createChildrenOptions( ); }, - useInteraction: interaction.link(() => ({ - href: searchResult.link, - newTab: true, - })), + useInteraction(searchEngine) { + if (searchEngine.type !== "fromIntegration") { + throw new Error("Invalid search engine type"); + } + + if (!searchEngine.integration) { + throw new Error("Invalid search engine integration"); + } + + if ( + getIntegrationKindsByCategory("mediaRequest").some( + (categoryKind) => categoryKind === searchEngine.integration?.kind, + ) && + "type" in searchResult + ) { + const type = searchResult.type; + if (type === "person") { + return { + type: "link", + href: searchResult.link, + newTab: true, + }; + } + + return { + type: "children", + ...mediaRequestsChildrenOptions({ + result: { + ...searchResult, + type, + }, + integration: searchEngine.integration, + }), + }; + } + + return { + type: "link", + href: searchResult.link, + newTab: true, + }; + }, })); }, DetailComponent({ options }) { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index b5b1872e3..cbe92028a 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2744,6 +2744,11 @@ } } }, + "media": { + "requestMovie": "Request movie", + "requestSeries": "Request series", + "openIn": "Open in {kind}" + }, "external": { "help": "Use an external search engine", "group": { @@ -2984,6 +2989,22 @@ } } } + }, + "media": { + "request": { + "modal": { + "title": "Request \"{name}\"", + "table": { + "header": { + "season": "Season", + "episodes": "Episodes" + } + }, + "button": { + "send": "Send request" + } + } + } } } } diff --git a/packages/validation/src/common.ts b/packages/validation/src/common.ts index 68506df63..3decf20be 100644 --- a/packages/validation/src/common.ts +++ b/packages/validation/src/common.ts @@ -15,8 +15,21 @@ const searchSchema = z.object({ limit: z.number().int().positive().default(10), }); +const mediaRequestOptionsSchema = z.object({ + mediaId: z.number(), + mediaType: z.enum(["tv", "movie"]), +}); + +const requestMediaSchema = z.object({ + mediaType: z.enum(["tv", "movie"]), + mediaId: z.number(), + seasons: z.array(z.number().min(0)).optional(), +}); + export const commonSchemas = { paginated: paginatedSchema, byId: byIdSchema, search: searchSchema, + mediaRequestOptions: mediaRequestOptionsSchema, + requestMedia: requestMediaSchema, };