import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import type { MediaRequestListInput } from "@homarr/request-handler/media-request-list"; import { mediaRequestListInputSchema, mediaRequestListRequestHandler, } from "@homarr/request-handler/media-request-list"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueriesWithProvenance } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const latestRequestsProcedure = publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .input(mediaRequestListInputSchema); const requestStatsProcedure = publicProcedure.concat( createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")), ); type MediaRequestIntegration = Parameters[0]; type MediaRequestStatsIntegration = Parameters[0]; const getLatestRequestsWithProvenanceAsync = async ( integrations: MediaRequestIntegration[], input: MediaRequestListInput, ) => { const { results, ...provenance } = await settleIntegrationQueriesWithProvenance( integrations, async (integration) => { const { data, isStale } = await mediaRequestListRequestHandler .handler(integration, input) .getDataWithProvenanceAsync(); return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data, isStale, }; }, { throwOnAllFailure: true }, ); const items = results .flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id, })), ) .toSorted((dataA, dataB) => { if (dataA.status === dataB.status) { return dataB.createdAt.getTime() - dataA.createdAt.getTime(); } return ( mediaRequestStatusConfiguration[dataA.status].position - mediaRequestStatusConfiguration[dataB.status].position ); }); return { items, ...provenance, }; }; const getRequestStatsWithProvenanceAsync = async (integrations: MediaRequestStatsIntegration[]) => { const { results, ...provenance } = await settleIntegrationQueriesWithProvenance( integrations, async (integration) => { const { data, isStale } = await mediaRequestStatsRequestHandler .handler(integration, {}) .getDataWithProvenanceAsync(); return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data, isStale, }; }, { throwOnAllFailure: true }, ); return { stats: results.flatMap((result) => result.data.stats), users: results .flatMap((result) => result.data.users.map((user) => ({ ...user, integration: result.integration, })), ) .toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA), integrations: results.map((result) => result.integration), ...provenance, }; }; export const mediaRequestsRouter = createTRPCRouter({ getLatestRequests: latestRequestsProcedure .meta({ mcp: { enabled: true, description: "Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).", }, }) .query(async ({ ctx, input }) => { return (await getLatestRequestsWithProvenanceAsync(ctx.integrations, input)).items; }), getLatestRequestsWithProvenance: latestRequestsProcedure.query(async ({ ctx, input }) => { return await getLatestRequestsWithProvenanceAsync(ctx.integrations, input); }), getStats: requestStatsProcedure .meta({ mcp: { enabled: true, description: "Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)", }, }) .query(async ({ ctx }) => { const result = await getRequestStatsWithProvenanceAsync(ctx.integrations); return { stats: result.stats, users: result.users, integrations: result.integrations, }; }), getStatsWithProvenance: requestStatsProcedure.query(async ({ ctx }) => { return await getRequestStatsWithProvenanceAsync(ctx.integrations); }), answerRequest: protectedProcedure .meta({ mcp: { enabled: true, description: "Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')", }, }) .concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest"))) .input( z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]), }), ) .mutation(async ({ ctx: { integration }, input }) => { const integrationInstance = await createIntegrationAsync(integration); const answerActions = { approve: (id: number) => integrationInstance.approveRequestAsync(id), decline: (id: number) => integrationInstance.declineRequestAsync(id), } as const; await answerActions[input.answer](input.requestId); }), });