Files
homarr/packages/api/src/router/widgets/media-requests.ts
T

159 lines
5.9 KiB
TypeScript

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<typeof mediaRequestListRequestHandler.handler>[0];
type MediaRequestStatsIntegration = Parameters<typeof mediaRequestStatsRequestHandler.handler>[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);
}),
});