diff --git a/.vscode/settings.json b/.vscode/settings.json index 90148c6f2..84dd7000b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,10 +12,13 @@ "cqmin", "homarr", "jellyfin", + "mantine", + "overseerr", + "Sonarr", "superjson", + "tabler", "trpc", - "Umami", - "Sonarr" + "Umami" ], "i18n-ally.dirStructure": "auto", "i18n-ally.enabledFrameworks": ["next-international"], diff --git a/packages/api/src/middlewares/integration.ts b/packages/api/src/middlewares/integration.ts index 0ec2ac11e..be91fecbd 100644 --- a/packages/api/src/middlewares/integration.ts +++ b/packages/api/src/middlewares/integration.ts @@ -126,7 +126,7 @@ export const createManyIntegrationMiddleware = ( if (offset !== 0) { throw new TRPCError({ code: "NOT_FOUND", - message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}`, + message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.join(",")}])`, }); } @@ -205,7 +205,7 @@ export const createManyIntegrationOfOneItemMiddleware = secret.source === "form") ?? secrets[0]!; }); + // @ts-expect-error - For now we expect an error here as not all integerations have been implemented const integrationInstance = integrationCreatorByKind(integration.kind, { id: integration.id, name: integration.name, diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 23cdede73..7ab978bb0 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -2,6 +2,7 @@ import { createTRPCRouter } from "../../trpc"; import { appRouter } from "./app"; import { calendarRouter } from "./calendar"; import { dnsHoleRouter } from "./dns-hole"; +import { mediaRequestsRouter } from "./media-requests"; import { mediaServerRouter } from "./media-server"; import { notebookRouter } from "./notebook"; import { rssFeedRouter } from "./rssFeed"; @@ -16,5 +17,6 @@ export const widgetRouter = createTRPCRouter({ smartHome: smartHomeRouter, mediaServer: mediaServerRouter, calendar: calendarRouter, + mediaRequests: mediaRequestsRouter, rssFeed: rssFeedRouter, }); diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts new file mode 100644 index 000000000..4a289e763 --- /dev/null +++ b/packages/api/src/router/widgets/media-requests.ts @@ -0,0 +1,48 @@ +import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations"; +import { integrationCreatorByKind } from "@homarr/integrations"; +import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { z } from "@homarr/validation"; + +import { + createManyIntegrationOfOneItemMiddleware, + createOneIntegrationMiddleware, +} from "../../middlewares/integration"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; + +export const mediaRequestsRouter = createTRPCRouter({ + getLatestRequests: publicProcedure + .unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "overseerr", "jellyseerr")) + .query(async ({ input }) => { + return await Promise.all( + input.integrationIds.map(async (integrationId) => { + const channel = createItemAndIntegrationChannel("mediaRequests-requestList", integrationId); + return await channel.getAsync(); + }), + ); + }), + getStats: publicProcedure + .unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "overseerr", "jellyseerr")) + .query(async ({ input }) => { + return await Promise.all( + input.integrationIds.map(async (integrationId) => { + const channel = createItemAndIntegrationChannel( + "mediaRequests-requestStats", + integrationId, + ); + return await channel.getAsync(); + }), + ); + }), + answerRequest: protectedProcedure + .unstable_concat(createOneIntegrationMiddleware("interact", "overseerr", "jellyseerr")) + .input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) })) + .mutation(async ({ ctx, input }) => { + const integration = integrationCreatorByKind(ctx.integration.kind, ctx.integration); + + if (input.answer === "approve") { + await integration.approveRequestAsync(input.requestId); + return; + } + await integration.declineRequestAsync(input.requestId); + }), +}); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 1923583dd..b28bd3eaa 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -2,6 +2,7 @@ import { analyticsJob } from "./jobs/analytics"; import { iconsUpdaterJob } from "./jobs/icons-updater"; import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant"; import { mediaOrganizerJob } from "./jobs/integrations/media-organizer"; +import { mediaRequestsJob } from "./jobs/integrations/media-requests"; import { mediaServerJob } from "./jobs/integrations/media-server"; import { pingJob } from "./jobs/ping"; import type { RssFeed } from "./jobs/rss-feeds"; @@ -15,6 +16,7 @@ export const jobGroup = createCronJobGroup({ smartHomeEntityState: smartHomeEntityStateJob, mediaServer: mediaServerJob, mediaOrganizer: mediaOrganizerJob, + mediaRequests: mediaRequestsJob, rssFeeds: rssFeedsJob, }); diff --git a/packages/cron-jobs/src/jobs/integrations/media-requests.ts b/packages/cron-jobs/src/jobs/integrations/media-requests.ts new file mode 100644 index 000000000..c97645bd5 --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/media-requests.ts @@ -0,0 +1,51 @@ +import { decryptSecret } from "@homarr/common"; +import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; +import { db } from "@homarr/db"; +import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; +import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations"; +import { integrationCreatorByKind } from "@homarr/integrations"; +import { createItemAndIntegrationChannel } from "@homarr/redis"; + +import { createCronJob } from "../../lib"; + +export const mediaRequestsJob = createCronJob("mediaRequests", EVERY_5_SECONDS).withCallback(async () => { + const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { + kinds: ["mediaRequests-requestList", "mediaRequests-requestStats"], + }); + + for (const itemForIntegration of itemsForIntegration) { + for (const { integration, integrationId } of itemForIntegration.integrations) { + const integrationWithSecrets = { + ...integration, + decryptedSecrets: integration.secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }; + + const requestsIntegration = integrationCreatorByKind(integration.kind, integrationWithSecrets); + + const mediaRequests = await requestsIntegration.getRequestsAsync(); + const requestsStats = await requestsIntegration.getStatsAsync(); + const requestsUsers = await requestsIntegration.getUsersAsync(); + const requestListChannel = createItemAndIntegrationChannel( + "mediaRequests-requestList", + integrationId, + ); + await requestListChannel.publishAndUpdateLastStateAsync({ + integration: { id: integration.id }, + medias: mediaRequests, + }); + + const requestStatsChannel = createItemAndIntegrationChannel( + "mediaRequests-requestStats", + integrationId, + ); + await requestStatsChannel.publishAndUpdateLastStateAsync({ + integration: { kind: integration.kind, name: integration.name }, + stats: requestsStats, + users: requestsUsers, + }); + } + } +}); diff --git a/packages/db/package.json b/packages/db/package.json index caf9e379f..4d71812c3 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -6,7 +6,8 @@ ".": "./index.ts", "./client": "./client.ts", "./schema/sqlite": "./schema/sqlite.ts", - "./test": "./test/index.ts" + "./test": "./test/index.ts", + "./queries": "./queries/index.ts" }, "private": true, "main": "./index.ts", diff --git a/packages/db/queries/index.ts b/packages/db/queries/index.ts new file mode 100644 index 000000000..2d645d6a1 --- /dev/null +++ b/packages/db/queries/index.ts @@ -0,0 +1 @@ +export * from "./item"; diff --git a/packages/db/queries/item.ts b/packages/db/queries/item.ts new file mode 100644 index 000000000..336270471 --- /dev/null +++ b/packages/db/queries/item.ts @@ -0,0 +1,47 @@ +import type { WidgetKind } from "@homarr/definitions"; + +import type { Database } from ".."; +import { inArray } from ".."; +import type { inferSupportedIntegrations } from "../../widgets/src"; +import { items } from "../schema/sqlite"; + +export const getItemsWithIntegrationsAsync = async ( + db: Database, + { kinds }: { kinds: TKind[] }, +) => { + const itemsForIntegration = await db.query.items.findMany({ + where: inArray(items.kind, kinds), + with: { + integrations: { + with: { + integration: { + with: { + secrets: { + columns: { + kind: true, + value: true, + }, + }, + }, + }, + }, + }, + }, + }); + + return itemsForIntegration.map((item) => ({ + ...item, + kind: item.kind as TKind, + integrations: item.integrations.map(({ integration, integrationId }) => { + const integrationWithSecrets = { + ...integration, + kind: integration.kind as inferSupportedIntegrations, + }; + + return { + integration: integrationWithSecrets, + integrationId, + }; + }), + })); +}; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 7682d5ab1..c6a498489 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -11,6 +11,8 @@ export const widgetKinds = [ "smartHome-executeAutomation", "mediaServer", "calendar", + "mediaRequests-requestList", + "mediaRequests-requestStats", "rssFeed", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 53d91a046..94f23b70f 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -3,23 +3,29 @@ import type { IntegrationKind } from "@homarr/definitions"; import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; +import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; +import { OverseerrIntegration } from "../overseerr/overseerr-integration"; import { PiHoleIntegration } from "../pi-hole/pi-hole-integration"; -import type { IntegrationInput } from "./integration"; +import type { Integration, IntegrationInput } from "./integration"; -export const integrationCreatorByKind = (kind: IntegrationKind, integration: IntegrationInput) => { - switch (kind) { - case "piHole": - return new PiHoleIntegration(integration); - case "adGuardHome": - return new AdGuardHomeIntegration(integration); - case "homeAssistant": - return new HomeAssistantIntegration(integration); - case "jellyfin": - return new JellyfinIntegration(integration); - case "sonarr": - return new SonarrIntegration(integration); - default: - throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`); +export const integrationCreatorByKind = ( + kind: TKind, + integration: IntegrationInput, +) => { + if (!(kind in integrationCreators)) { + throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`); } + + return new integrationCreators[kind](integration) as InstanceType<(typeof integrationCreators)[TKind]>; }; + +export const integrationCreators = { + piHole: PiHoleIntegration, + adGuardHome: AdGuardHomeIntegration, + homeAssistant: HomeAssistantIntegration, + jellyfin: JellyfinIntegration, + sonarr: SonarrIntegration, + jellyseerr: JellyseerrIntegration, + overseerr: OverseerrIntegration, +} satisfies Partial Integration>>; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 5a7dc7eb7..c603976e7 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -3,10 +3,14 @@ export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration" export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration"; export { JellyfinIntegration } from "./jellyfin/jellyfin-integration"; export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration"; +export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration"; +export { OverseerrIntegration } from "./overseerr/overseerr-integration"; export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; // Types export type { StreamSession } from "./interfaces/media-server/session"; +export { MediaRequestStatus } from "./interfaces/media-requests/media-request"; +export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request"; // Helpers export { integrationCreatorByKind } from "./base/creator"; diff --git a/packages/integrations/src/interfaces/media-requests/media-request.ts b/packages/integrations/src/interfaces/media-requests/media-request.ts new file mode 100644 index 000000000..51c97c24c --- /dev/null +++ b/packages/integrations/src/interfaces/media-requests/media-request.ts @@ -0,0 +1,62 @@ +export interface MediaRequest { + id: number; + name: string; + type: "movie" | "tv"; + backdropImageUrl: string; + posterImagePath: string; + href: string; + createdAt: Date; + airDate?: Date; + status: MediaRequestStatus; + availability: MediaAvailability; + requestedBy?: Omit; +} + +export interface MediaRequestList { + integration: { + id: string; + }; + medias: MediaRequest[]; +} + +export interface RequestStats { + total: number; + movie: number; + tv: number; + pending: number; + approved: number; + declined: number; + processing: number; + available: number; +} + +export interface RequestUser { + id: number; + displayName: string; + avatar: string; + requestCount: number; + link: string; +} + +export interface MediaRequestStats { + integration: { + kind: string; + name: string; + }; + stats: RequestStats; + users: RequestUser[]; +} + +export enum MediaRequestStatus { + PendingApproval = 1, + Approved = 2, + Declined = 3, +} + +export enum MediaAvailability { + Unknown = 1, + Pending = 2, + Processing = 3, + PartiallyAvailable = 4, + Available = 5, +} diff --git a/packages/integrations/src/jellyseerr/jellyseerr-integration.ts b/packages/integrations/src/jellyseerr/jellyseerr-integration.ts new file mode 100644 index 000000000..c9e3a6193 --- /dev/null +++ b/packages/integrations/src/jellyseerr/jellyseerr-integration.ts @@ -0,0 +1,3 @@ +import { OverseerrIntegration } from "../overseerr/overseerr-integration"; + +export class JellyseerrIntegration extends OverseerrIntegration {} diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts new file mode 100644 index 000000000..9de4c316c --- /dev/null +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -0,0 +1,241 @@ +import { z } from "@homarr/validation"; + +import { Integration } from "../base/integration"; +import type { MediaRequest, RequestStats, RequestUser } from "../interfaces/media-requests/media-request"; +import { MediaAvailability, MediaRequestStatus } from "../interfaces/media-requests/media-request"; + +/** + * Overseerr Integration. See https://api-docs.overseerr.dev + */ +export class OverseerrIntegration extends Integration { + public async testConnectionAsync(): Promise { + const response = await fetch(`${this.integration.url}/api/v1/auth/me`, { + headers: { + "X-Api-Key": this.getSecretValue("apiKey"), + }, + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const json: object = await response.json(); + if (Object.keys(json).includes("id")) { + return; + } + + throw new Error(`Received response but unable to parse it: ${JSON.stringify(json)}`); + } + + public async getRequestsAsync(): Promise { + //Ensure to get all pending request first + const pendingRequests = await fetch(`${this.integration.url}/api/v1/request?take=-1&filter=pending`, { + headers: { + "X-Api-Key": this.getSecretValue("apiKey"), + }, + }); + + //Change 20 to integration setting (set to -1 for all) + const allRequests = await fetch(`${this.integration.url}/api/v1/request?take=20`, { + headers: { + "X-Api-Key": this.getSecretValue("apiKey"), + }, + }); + + const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results; + const allResults = (await getRequestsSchema.parseAsync(await allRequests.json())).results; + + //Concat the 2 lists while remove any duplicate pending from the all items list + let requests; + + if (pendingResults.length > 0 && allResults.length > 0) { + requests = pendingResults.concat( + allResults.filter(({ status }) => status !== MediaRequestStatus.PendingApproval), + ); + } else if (pendingResults.length > 0) requests = pendingResults; + else if (allResults.length > 0) requests = allResults; + else return Promise.all([]); + + return await Promise.all( + requests.map(async (request): Promise => { + const information = await this.getItemInformationAsync(request.media.tmdbId, request.type); + return { + id: request.id, + name: information.name, + status: request.status, + availability: request.media.status, + backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`, + posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`, + href: `${this.integration.url}/${request.type}/${request.media.tmdbId}`, + type: request.type, + createdAt: request.createdAt, + airDate: new Date(information.airDate), + requestedBy: request.requestedBy + ? ({ + ...request.requestedBy, + displayName: request.requestedBy.displayName, + link: `${this.integration.url}/users/${request.requestedBy.id}`, + avatar: constructAvatarUrl(this.integration.url, request.requestedBy.avatar), + } satisfies Omit) + : undefined, + }; + }), + ); + } + + public async getStatsAsync(): Promise { + const response = await fetch(`${this.integration.url}/api/v1/request/count`, { + headers: { + "X-Api-Key": this.getSecretValue("apiKey"), + }, + }); + return await getStatsSchema.parseAsync(await response.json()); + } + + public async getUsersAsync(): Promise { + const response = await fetch(`${this.integration.url}/api/v1/user?take=-1`, { + headers: { + "X-Api-Key": this.getSecretValue("apiKey"), + }, + }); + const users = (await getUsersSchema.parseAsync(await response.json())).results; + return users.map((user): RequestUser => { + return { + ...user, + link: `${this.integration.url}/users/${user.id}`, + avatar: constructAvatarUrl(this.integration.url, user.avatar), + }; + }); + } + + public async approveRequestAsync(requestId: number): Promise { + await fetch(`${this.integration.url}/api/v1/request/${requestId}/approve`, { + method: "POST", + headers: { + "X-Api-Key": this.getSecretValue("apiKey"), + }, + }); + } + + public async declineRequestAsync(requestId: number): Promise { + await fetch(`${this.integration.url}/api/v1/request/${requestId}/decline`, { + method: "POST", + headers: { + "X-Api-Key": this.getSecretValue("apiKey"), + }, + }); + } + + private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise { + const response = await fetch(`${this.integration.url}/api/v1/${type}/${id}`, { + headers: { + "X-Api-Key": this.getSecretValue("apiKey"), + }, + }); + + if (type === "tv") { + const series = (await response.json()) as TvInformation; + return { + name: series.name, + backdropPath: series.backdropPath ?? series.posterPath, + posterPath: series.posterPath ?? series.backdropPath, + airDate: series.firstAirDate, + } satisfies MediaInformation; + } + + const movie = (await response.json()) as MovieInformation; + return { + name: movie.title, + backdropPath: movie.backdropPath ?? movie.posterPath, + posterPath: movie.posterPath ?? movie.backdropPath, + airDate: movie.releaseDate, + } satisfies MediaInformation; + } +} + +const constructAvatarUrl = (appUrl: string, avatar: string) => { + const isAbsolute = avatar.startsWith("http://") || avatar.startsWith("https://"); + + if (isAbsolute) { + return avatar; + } + + return `${appUrl}/${avatar}`; +}; + +interface MediaInformation { + name: string; + backdropPath?: string; + posterPath?: string; + airDate: string; +} + +interface TvInformation { + name: string; + backdropPath?: string; + posterPath?: string; + firstAirDate: string; +} + +interface MovieInformation { + title: string; + backdropPath?: string; + posterPath?: string; + releaseDate: string; +} + +const getRequestsSchema = z.object({ + results: z + .array( + z.object({ + id: z.number(), + status: z.nativeEnum(MediaRequestStatus), + createdAt: z.string().transform((value) => new Date(value)), + media: z.object({ + status: z.nativeEnum(MediaAvailability), + tmdbId: z.number(), + }), + type: z.enum(["movie", "tv"]), + requestedBy: z + .object({ + id: z.number(), + displayName: z.string(), + avatar: z.string(), + }) + .optional(), + }), + ) + .optional() + .transform((val) => { + if (!val) { + return []; + } + return val; + }), +}); + +const getStatsSchema = z.object({ + total: z.number(), + movie: z.number(), + tv: z.number(), + pending: z.number(), + approved: z.number(), + declined: z.number(), + processing: z.number(), + available: z.number(), +}); + +const getUsersSchema = z.object({ + results: z + .array( + z.object({ + id: z.number(), + displayName: z.string(), + avatar: z.string(), + requestCount: z.number(), + }), + ) + .optional() + .transform((val) => { + if (!val) { + return []; + } + return val; + }), +}); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index 3a688288f..a39c1d577 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -1,2 +1,3 @@ export * from "./interfaces/dns-hole-summary/dns-hole-summary-types"; export * from "./calendar-types"; +export * from "./interfaces/media-requests/media-request"; diff --git a/packages/integrations/test/home-assistant.spec.ts b/packages/integrations/test/home-assistant.spec.ts index 76d8446e6..1c1696581 100644 --- a/packages/integrations/test/home-assistant.spec.ts +++ b/packages/integrations/test/home-assistant.spec.ts @@ -27,7 +27,7 @@ describe("Home Assistant integration", () => { // Cleanup await startedContainer.stop(); - }, 20_000); // Timeout of 20 seconds + }, 30_000); // Timeout of 30 seconds test("Test connection should fail with wrong credentials", async () => { // Arrange const startedContainer = await prepareHomeAssistantContainerAsync(); @@ -41,7 +41,7 @@ describe("Home Assistant integration", () => { // Cleanup await startedContainer.stop(); - }, 20_000); // Timeout of 20 seconds + }, 30_000); // Timeout of 30 seconds }); const prepareHomeAssistantContainerAsync = async () => { diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 4d2a66485..79f157ba7 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -531,6 +531,10 @@ export default { colon: ": ", }, error: "Error", + errors: { + noData: "No data to show", + noIntegration: "No integration selected", + }, action: { add: "Add", apply: "Apply", @@ -1115,6 +1119,50 @@ export default { description: "Show the current streams on your media servers", option: {}, }, + "mediaRequests-requestList": { + name: "Media Requests List", + description: "See a list of all media requests from your Overseerr or Jellyseerr instance", + option: { + linksTargetNewTab: { + label: "Open links in new tab", + }, + }, + pending: { + approve: "Approve request", + approving: "Approving request...", + decline: "Decline request", + }, + availability: { + unknown: "Unknown", + pending: "Pending", + processing: "Processing", + partiallyAvailable: "Partial", + available: "Available", + }, + toBeDetermined: "TBD", + }, + "mediaRequests-requestStats": { + name: "Media Requests Stats", + description: "Statistics about your media requests", + option: {}, + titles: { + stats: { + main: "Media Stats", + approved: "Already approved", + pending: "Pending approvals", + processing: "Being processed", + declined: "Already declined", + available: "Already Available", + tv: "TV requests", + movie: "Movie requests", + total: "Total", + }, + users: { + main: "Top Users", + requests: "Requests", + }, + }, + }, rssFeed: { name: "RSS feeds", description: "Monitor and display one or more generic RSS, ATOM or JSON feeds", @@ -1602,6 +1650,9 @@ export default { mediaOrganizer: { label: "Media Organizers", }, + mediaRequests: { + label: "Media Requests", + }, rssFeeds: { label: "RSS feeds", }, diff --git a/packages/translation/src/type.ts b/packages/translation/src/type.ts index cd3c12d8e..e2baf031f 100644 --- a/packages/translation/src/type.ts +++ b/packages/translation/src/type.ts @@ -1,6 +1,9 @@ -import type { useI18n } from "./client"; +import type { useI18n, useScopedI18n } from "./client"; import type enTranslation from "./lang/en"; export type TranslationFunction = ReturnType; +export type ScopedTranslationFunction[0]> = ReturnType< + typeof useScopedI18n +>; export type TranslationObject = typeof enTranslation; export type stringOrTranslation = string | ((t: TranslationFunction) => string); diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx index 0d09fae0a..a4de98bf4 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -30,7 +30,7 @@ export default function ClockWidget({ options }: WidgetComponentProps<"clock">) {dayjs(time).tz(timezone).format(timeFormat)} {options.showDate && ( - + {dayjs(time).tz(timezone).format(dateFormat)} )} diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index cbc229559..0585f760d 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -13,6 +13,8 @@ import * as dnsHoleControls from "./dns-hole/controls"; import * as dnsHoleSummary from "./dns-hole/summary"; import * as iframe from "./iframe"; import type { WidgetImportRecord } from "./import"; +import * as mediaRequestsList from "./media-requests/list"; +import * as mediaRequestsStats from "./media-requests/stats"; import * as mediaServer from "./media-server"; import * as notebook from "./notebook"; import * as rssFeed from "./rssFeed"; @@ -42,6 +44,8 @@ export const widgetImports = { "smartHome-executeAutomation": smartHomeExecuteAutomation, mediaServer, calendar, + "mediaRequests-requestList": mediaRequestsList, + "mediaRequests-requestStats": mediaRequestsStats, rssFeed, } satisfies WidgetImportRecord; @@ -64,3 +68,9 @@ export const loadWidgetDynamic = (kind: TKind) => { loadedComponents.set(kind, newlyLoadedComponent as never); return newlyLoadedComponent; }; + +export type inferSupportedIntegrations = (WidgetImports[TKind]["definition"] extends { + supportedIntegrations: string[]; +} + ? WidgetImports[TKind]["definition"]["supportedIntegrations"] + : string[])[number]; diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx new file mode 100644 index 000000000..e1f40ded5 --- /dev/null +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -0,0 +1,230 @@ +import { useMemo } from "react"; +import { + ActionIcon, + Anchor, + Avatar, + Badge, + Card, + Center, + Group, + Image, + ScrollArea, + Stack, + Text, + Tooltip, +} from "@mantine/core"; +import { IconThumbDown, IconThumbUp } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { MediaAvailability, MediaRequestStatus } from "@homarr/integrations/types"; +import type { ScopedTranslationFunction } from "@homarr/translation"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../../definition"; + +export default function MediaServerWidget({ + integrationIds, + isEditMode, + options, + serverData, + itemId, +}: WidgetComponentProps<"mediaRequests-requestList">) { + const t = useScopedI18n("widget.mediaRequests-requestList"); + const tCommon = useScopedI18n("common"); + const isQueryEnabled = Boolean(itemId); + const { data: mediaRequests, isError: _isError } = clientApi.widget.mediaRequests.getLatestRequests.useQuery( + { + integrationIds, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + itemId: itemId!, + }, + { + initialData: !serverData ? undefined : serverData.initialData, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + enabled: integrationIds.length > 0 && isQueryEnabled, + }, + ); + + const sortedMediaRequests = useMemo( + () => + mediaRequests + ?.filter((group) => group != null) + .flatMap((group) => group.data) + .flatMap(({ medias, integration }) => medias.map((media) => ({ ...media, integrationId: integration.id }))) + .sort(({ status: statusA }, { status: statusB }) => { + if (statusA === MediaRequestStatus.PendingApproval) { + return -1; + } + if (statusB === MediaRequestStatus.PendingApproval) { + return 1; + } + return 0; + }) ?? [], + [mediaRequests, integrationIds], + ); + + const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation(); + + if (integrationIds.length === 0) return
{tCommon("errors.noIntegration")}
; + + if (sortedMediaRequests.length === 0) return
{tCommon("errors.noData")}
; + + return ( + + + {sortedMediaRequests.map((mediaRequest) => ( + + + + + + + + + + {mediaRequest.airDate?.getFullYear() ?? t("toBeDetermined")} + + + {getAvailabilityProperties(mediaRequest.availability, t).label} + + + + {mediaRequest.name || "unknown"} + + + + + + + + {(mediaRequest.requestedBy?.displayName ?? "") || "unknown"} + + + {mediaRequest.status === MediaRequestStatus.PendingApproval && ( + + + { + mutateRequestAnswer({ + integrationId: mediaRequest.integrationId, + requestId: mediaRequest.id, + answer: "approve", + }); + }} + > + + + + + { + mutateRequestAnswer({ + integrationId: mediaRequest.integrationId, + requestId: mediaRequest.id, + answer: "decline", + }); + }} + > + + + + + )} + + + + ))} + + + ); +} + +function getAvailabilityProperties( + mediaRequestAvailability: MediaAvailability, + t: ScopedTranslationFunction<"widget.mediaRequests-requestList">, +) { + switch (mediaRequestAvailability) { + case MediaAvailability.Available: + return { color: "green", label: t("availability.available") }; + case MediaAvailability.PartiallyAvailable: + return { color: "yellow", label: t("availability.partiallyAvailable") }; + case MediaAvailability.Pending: + return { color: "violet", label: t("availability.pending") }; + case MediaAvailability.Processing: + return { color: "blue", label: t("availability.processing") }; + default: + return { color: "red", label: t("availability.unknown") }; + } +} diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts new file mode 100644 index 000000000..4fe23f895 --- /dev/null +++ b/packages/widgets/src/media-requests/list/index.ts @@ -0,0 +1,16 @@ +import { IconZoomQuestion } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../../definition"; +import { optionsBuilder } from "../../options"; + +export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestList", { + icon: IconZoomQuestion, + options: optionsBuilder.from((factory) => ({ + linksTargetNewTab: factory.switch({ + defaultValue: true, + }), + })), + supportedIntegrations: ["overseerr", "jellyseerr"], +}) + .withServerData(() => import("./serverData")) + .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-requests/list/serverData.ts b/packages/widgets/src/media-requests/list/serverData.ts new file mode 100644 index 000000000..8784e4dd3 --- /dev/null +++ b/packages/widgets/src/media-requests/list/serverData.ts @@ -0,0 +1,22 @@ +"use server"; + +import { api } from "@homarr/api/server"; + +import type { WidgetProps } from "../../definition"; + +export default async function getServerDataAsync({ integrationIds, itemId }: WidgetProps<"mediaRequests-requestList">) { + if (integrationIds.length === 0 || !itemId) { + return { + initialData: undefined, + }; + } + + const requests = await api.widget.mediaRequests.getLatestRequests({ + integrationIds, + itemId, + }); + + return { + initialData: requests.filter((group) => group != null), + }; +} diff --git a/packages/widgets/src/media-requests/stats/component.module.css b/packages/widgets/src/media-requests/stats/component.module.css new file mode 100644 index 000000000..9e23feacf --- /dev/null +++ b/packages/widgets/src/media-requests/stats/component.module.css @@ -0,0 +1,7 @@ +.gridElement:not(:nth-child(8n)) { + border-right: 0.5cqmin solid var(--app-shell-border-color); +} + +.gridElement:not(:nth-last-child(-n + 8)) { + border-bottom: 0.5cqmin solid var(--app-shell-border-color); +} diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx new file mode 100644 index 000000000..55eb88090 --- /dev/null +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -0,0 +1,220 @@ +import { useMemo } from "react"; +import { ActionIcon, Avatar, Card, Center, Grid, Group, Space, Stack, Text, Tooltip } from "@mantine/core"; +import { useElementSize } from "@mantine/hooks"; +import type { Icon } from "@tabler/icons-react"; +import { + IconDeviceTv, + IconExternalLink, + IconHourglass, + IconLoaderQuarter, + IconMovie, + IconPlayerPlay, + IconReceipt, + IconThumbDown, + IconThumbUp, +} from "@tabler/icons-react"; +import combineClasses from "clsx"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { RequestStats } from "../../../../integrations/src/interfaces/media-requests/media-request"; +import type { WidgetComponentProps } from "../../definition"; +import classes from "./component.module.css"; + +export default function MediaServerWidget({ + integrationIds, + isEditMode, + serverData, + itemId, +}: WidgetComponentProps<"mediaRequests-requestStats">) { + const t = useScopedI18n("widget.mediaRequests-requestStats"); + const tCommon = useScopedI18n("common"); + const isQueryEnabled = Boolean(itemId); + const { data: requestStats, isError: _isError } = clientApi.widget.mediaRequests.getStats.useQuery( + { + integrationIds, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + itemId: itemId!, + }, + { + initialData: !serverData ? undefined : serverData.initialData, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + enabled: integrationIds.length > 0 && isQueryEnabled, + }, + ); + + const { width, height, ref } = useElementSize(); + + const baseData = useMemo( + () => requestStats?.filter((group) => group != null).flatMap((group) => group.data) ?? [], + [requestStats], + ); + + const stats = useMemo(() => baseData.flatMap(({ stats }) => stats), [baseData]); + const users = useMemo( + () => + baseData + .flatMap(({ integration, users }) => + users.flatMap((user) => ({ ...user, appKind: integration.kind, appName: integration.name })), + ) + .sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA), + [baseData], + ); + + if (integrationIds.length === 0) + return ( +
+ {tCommon("errors.noIntegration")} +
+ ); + + if (users.length === 0 || stats.length === 0) + return ( +
+ {tCommon("errors.noData")} +
+ ); + + //Add processing and available + const data = [ + { + name: "approved", + icon: IconThumbUp, + number: stats.reduce((count, { approved }) => count + approved, 0), + }, + { + name: "pending", + icon: IconHourglass, + number: stats.reduce((count, { pending }) => count + pending, 0), + }, + { + name: "processing", + icon: IconLoaderQuarter, + number: stats.reduce((count, { processing }) => count + processing, 0), + }, + { + name: "declined", + icon: IconThumbDown, + number: stats.reduce((count, { declined }) => count + declined, 0), + }, + { + name: "available", + icon: IconPlayerPlay, + number: stats.reduce((count, { available }) => count + available, 0), + }, + { + name: "tv", + icon: IconDeviceTv, + number: stats.reduce((count, { tv }) => count + tv, 0), + }, + { + name: "movie", + icon: IconMovie, + number: stats.reduce((count, { movie }) => count + movie, 0), + }, + { + name: "total", + icon: IconReceipt, + number: stats.reduce((count, { total }) => count + total, 0), + }, + ] satisfies { name: keyof RequestStats; icon: Icon; number: number }[]; + + return ( + + + {t("titles.stats.main")} + + + {data.map((stat) => ( + + + + + + {stat.number} + + + + + ))} + + + {t("titles.users.main")} + + + {users.slice(0, Math.max(Math.floor((height / width) * 5), 1)).map((user) => ( + + + + + + + + {user.displayName} + + + {tCommon("rtl", { value: t("titles.users.requests"), symbol: tCommon("symbols.colon") }) + + user.requestCount} + + + + + + + + + ))} + + + ); +} diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts new file mode 100644 index 000000000..332a0885b --- /dev/null +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -0,0 +1,11 @@ +import { IconChartBar } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../../definition"; + +export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestStats", { + icon: IconChartBar, + options: {}, + supportedIntegrations: ["overseerr", "jellyseerr"], +}) + .withServerData(() => import("./serverData")) + .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-requests/stats/serverData.ts b/packages/widgets/src/media-requests/stats/serverData.ts new file mode 100644 index 000000000..a534e0a6d --- /dev/null +++ b/packages/widgets/src/media-requests/stats/serverData.ts @@ -0,0 +1,25 @@ +"use server"; + +import { api } from "@homarr/api/server"; + +import type { WidgetProps } from "../../definition"; + +export default async function getServerDataAsync({ + integrationIds, + itemId, +}: WidgetProps<"mediaRequests-requestStats">) { + if (integrationIds.length === 0 || !itemId) { + return { + initialData: undefined, + }; + } + + const stats = await api.widget.mediaRequests.getStats({ + integrationIds, + itemId, + }); + + return { + initialData: stats.filter((group) => group != null), + }; +} diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index d7ad12484..ed487f38e 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -9,7 +9,7 @@ import { clientApi } from "@homarr/api/client"; import type { WidgetComponentProps } from "../definition"; import { WeatherDescription, WeatherIcon } from "./icon"; -export default function WeatherWidget({ options }: WidgetComponentProps<"weather">) { +export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) { const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery( { latitude: options.location.latitude, @@ -23,7 +23,14 @@ export default function WeatherWidget({ options }: WidgetComponentProps<"weather ); return ( - + {options.hasForecast ? ( ) : ( @@ -51,15 +58,15 @@ const DailyWeather = ({ options, weather }: WeatherProps) => { - {getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)} + {getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)} - {getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit)} + {getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit)} - {getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit)} + {getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit)} {options.showCity && ( <>