Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add media requests widget #774

Merged
merged 19 commits into from
Aug 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
4 changes: 2 additions & 2 deletions packages/api/src/middlewares/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
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(",")}])`,
});
}

Expand Down Expand Up @@ -205,7 +205,7 @@ export const createManyIntegrationOfOneItemMiddleware = <TKind extends Integrati
if (dbIntegrationWithItem.length === 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration for item was not found",
message: "Integrations for item were not found",
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const testConnectionAsync = async (
return secrets.find((secret) => 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,
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/router/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -16,5 +17,6 @@ export const widgetRouter = createTRPCRouter({
smartHome: smartHomeRouter,
mediaServer: mediaServerRouter,
calendar: calendarRouter,
mediaRequests: mediaRequestsRouter,
rssFeed: rssFeedRouter,
});
48 changes: 48 additions & 0 deletions packages/api/src/router/widgets/media-requests.ts
Original file line number Diff line number Diff line change
@@ -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<MediaRequestList>("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<MediaRequestStats>(
"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);
}),
});
2 changes: 2 additions & 0 deletions packages/cron-jobs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -15,6 +16,7 @@ export const jobGroup = createCronJobGroup({
smartHomeEntityState: smartHomeEntityStateJob,
mediaServer: mediaServerJob,
mediaOrganizer: mediaOrganizerJob,
mediaRequests: mediaRequestsJob,
rssFeeds: rssFeedsJob,
});

Expand Down
51 changes: 51 additions & 0 deletions packages/cron-jobs/src/jobs/integrations/media-requests.ts
Original file line number Diff line number Diff line change
@@ -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<MediaRequestList>(
"mediaRequests-requestList",
integrationId,
);
await requestListChannel.publishAndUpdateLastStateAsync({
integration: { id: integration.id },
medias: mediaRequests,
});

const requestStatsChannel = createItemAndIntegrationChannel<MediaRequestStats>(
"mediaRequests-requestStats",
integrationId,
);
await requestStatsChannel.publishAndUpdateLastStateAsync({
integration: { kind: integration.kind, name: integration.name },
stats: requestsStats,
users: requestsUsers,
});
}
}
});
3 changes: 2 additions & 1 deletion packages/db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/db/queries/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./item";
47 changes: 47 additions & 0 deletions packages/db/queries/item.ts
Original file line number Diff line number Diff line change
@@ -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 <TKind extends WidgetKind>(
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<TKind>,
};

return {
integration: integrationWithSecrets,
integrationId,
};
}),
}));
};
2 changes: 2 additions & 0 deletions packages/definitions/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const widgetKinds = [
"smartHome-executeAutomation",
"mediaServer",
"calendar",
"mediaRequests-requestList",
"mediaRequests-requestStats",
"rssFeed",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
36 changes: 21 additions & 15 deletions packages/integrations/src/base/creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <TKind extends keyof typeof integrationCreators>(
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<Record<IntegrationKind, new (integration: IntegrationInput) => Integration>>;
4 changes: 4 additions & 0 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RequestUser, "requestCount">;
}

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,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { OverseerrIntegration } from "../overseerr/overseerr-integration";

export class JellyseerrIntegration extends OverseerrIntegration {}
Loading