feat: add media requests widget (#774)

Co-authored-by: SeDemal <Tagaishi@hotmail.ch>
Co-authored-by: SeDemal <demal.sebastien@bluewin.ch>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2024-08-24 15:23:16 +02:00
committed by GitHub
parent 7ec4adcb24
commit acbb834889
30 changed files with 1106 additions and 29 deletions

View File

@@ -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"],

View File

@@ -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(",")}])`,
});
}
@@ -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",
});
}

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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);
}),
});

View File

@@ -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,
});

View File

@@ -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,
});
}
}
});

View File

@@ -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",

View File

@@ -0,0 +1 @@
export * from "./item";

View File

@@ -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,
};
}),
}));
};

View File

@@ -11,6 +11,8 @@ export const widgetKinds = [
"smartHome-executeAutomation",
"mediaServer",
"calendar",
"mediaRequests-requestList",
"mediaRequests-requestStats",
"rssFeed",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

@@ -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>>;

View File

@@ -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";

View File

@@ -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,
}

View File

@@ -0,0 +1,3 @@
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
export class JellyseerrIntegration extends OverseerrIntegration {}

View File

@@ -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<void> {
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<MediaRequest[]> {
//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<MediaRequest> => {
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<RequestUser, "requestCount">)
: undefined,
};
}),
);
}
public async getStatsAsync(): Promise<RequestStats> {
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<RequestUser[]> {
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<void> {
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<void> {
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<MediaInformation> {
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;
}),
});

View File

@@ -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";

View File

@@ -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 () => {

View File

@@ -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",
},

View File

@@ -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<typeof useI18n>;
export type ScopedTranslationFunction<T extends Parameters<typeof useScopedI18n>[0]> = ReturnType<
typeof useScopedI18n<T>
>;
export type TranslationObject = typeof enTranslation;
export type stringOrTranslation = string | ((t: TranslationFunction) => string);

View File

@@ -30,7 +30,7 @@ export default function ClockWidget({ options }: WidgetComponentProps<"clock">)
{dayjs(time).tz(timezone).format(timeFormat)}
</Text>
{options.showDate && (
<Text className="clock-date-text" size="12.5cqmin" lineClamp={1}>
<Text className="clock-date-text" size="12.5cqmin" pt="1cqmin" lineClamp={1}>
{dayjs(time).tz(timezone).format(dateFormat)}
</Text>
)}

View File

@@ -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 = <TKind extends WidgetKind>(kind: TKind) => {
loadedComponents.set(kind, newlyLoadedComponent as never);
return newlyLoadedComponent;
};
export type inferSupportedIntegrations<TKind extends WidgetKind> = (WidgetImports[TKind]["definition"] extends {
supportedIntegrations: string[];
}
? WidgetImports[TKind]["definition"]["supportedIntegrations"]
: string[])[number];

View File

@@ -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 <Center h="100%">{tCommon("errors.noIntegration")}</Center>;
if (sortedMediaRequests.length === 0) return <Center h="100%">{tCommon("errors.noData")}</Center>;
return (
<ScrollArea
className="mediaRequests-list-scrollArea"
scrollbarSize="2cqmin"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack className="mediaRequests-list-list" gap="2cqmin" p="2cqmin">
{sortedMediaRequests.map((mediaRequest) => (
<Card
className={`mediaRequests-list-item-wrapper mediaRequests-list-item-${mediaRequest.type} mediaRequests-list-item-${mediaRequest.status}`}
key={mediaRequest.id}
h="20cqmin"
radius="2cqmin"
p="2cqmin"
withBorder
>
<Image
className="mediaRequests-list-item-background"
src={mediaRequest.backdropImageUrl}
pos="absolute"
w="100%"
h="100%"
opacity={0.2}
top={0}
left={0}
alt=""
/>
<Group
className="mediaRequests-list-item-contents"
h="100%"
style={{ zIndex: 1 }}
justify="space-between"
wrap="nowrap"
gap={0}
>
<Group className="mediaRequests-list-item-left-side" h="100%" gap="4cqmin" wrap="nowrap" flex={1}>
<Image
className="mediaRequests-list-item-poster"
src={mediaRequest.posterImagePath}
h="100%"
w="10cqmin"
radius="1cqmin"
/>
<Stack className="mediaRequests-list-item-media-infos" gap="1cqmin">
<Group className="mediaRequests-list-item-info-first-line" gap="2cqmin" wrap="nowrap">
<Text className="mediaRequests-list-item-media-year" size="3.5cqmin" pt="0.75cqmin">
{mediaRequest.airDate?.getFullYear() ?? t("toBeDetermined")}
</Text>
<Badge
className="mediaRequests-list-item-media-status"
color={getAvailabilityProperties(mediaRequest.availability, t).color}
variant="light"
fz="3.5cqmin"
lh="4cqmin"
size="5cqmin"
pt="0.75cqmin"
px="2cqmin"
>
{getAvailabilityProperties(mediaRequest.availability, t).label}
</Badge>
</Group>
<Anchor
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
href={mediaRequest.href}
c="var(--mantine-color-text)"
target={options.linksTargetNewTab ? "_blank" : "_self"}
fz="5cqmin"
lineClamp={1}
>
{mediaRequest.name || "unknown"}
</Anchor>
</Stack>
</Group>
<Stack className="mediaRequests-list-item-right-side" gap="1cqmin" align="end">
<Group className="mediaRequests-list-item-request-user" gap="2cqmin" wrap="nowrap">
<Avatar
className="mediaRequests-list-item-request-user-avatar"
src={mediaRequest.requestedBy?.avatar}
size="6cqmin"
/>
<Anchor
className="mediaRequests-list-item-request-user-name"
href={mediaRequest.requestedBy?.link}
c="var(--mantine-color-text)"
target={options.linksTargetNewTab ? "_blank" : "_self"}
fz="5cqmin"
lineClamp={1}
style={{ wordBreak: "break-all" }}
>
{(mediaRequest.requestedBy?.displayName ?? "") || "unknown"}
</Anchor>
</Group>
{mediaRequest.status === MediaRequestStatus.PendingApproval && (
<Group className="mediaRequests-list-item-pending-buttons" gap="2cqmin">
<Tooltip label={t("pending.approve")}>
<ActionIcon
className="mediaRequests-list-item-pending-button-approve"
variant="light"
color="green"
size="5cqmin"
onClick={() => {
mutateRequestAnswer({
integrationId: mediaRequest.integrationId,
requestId: mediaRequest.id,
answer: "approve",
});
}}
>
<IconThumbUp size="4cqmin" />
</ActionIcon>
</Tooltip>
<Tooltip label={t("pending.decline")}>
<ActionIcon
className="mediaRequests-list-item-pending-button-decline"
variant="light"
color="red"
size="5cqmin"
onClick={() => {
mutateRequestAnswer({
integrationId: mediaRequest.integrationId,
requestId: mediaRequest.id,
answer: "decline",
});
}}
>
<IconThumbDown size="4cqmin" />
</ActionIcon>
</Tooltip>
</Group>
)}
</Stack>
</Group>
</Card>
))}
</Stack>
</ScrollArea>
);
}
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") };
}
}

View File

@@ -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"));

View File

@@ -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),
};
}

View File

@@ -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);
}

View File

@@ -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 (
<Center ref={ref} h="100%">
{tCommon("errors.noIntegration")}
</Center>
);
if (users.length === 0 || stats.length === 0)
return (
<Center ref={ref} h="100%">
{tCommon("errors.noData")}
</Center>
);
//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 (
<Stack
className="mediaRequests-stats-layout"
display="flex"
h="100%"
gap="2cqmin"
p="2cqmin"
align="center"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Text className="mediaRequests-stats-stats-title" size="6.5cqmin">
{t("titles.stats.main")}
</Text>
<Grid className="mediaRequests-stats-stats-grid" gutter={0} w="100%">
{data.map((stat) => (
<Grid.Col
className={combineClasses(
classes.gridElement,
"mediaRequests-stats-stat-wrapper",
`mediaRequests-stats-stat-${stat.name}`,
)}
key={stat.name}
span={3}
>
<Tooltip label={t(`titles.stats.${stat.name}`)}>
<Stack className="mediaRequests-stats-stat-stack" align="center" gap="2cqmin" p="2cqmin">
<stat.icon className="mediaRequests-stats-stat-icon" size="7.5cqmin" />
<Text className="mediaRequests-stats-stat-value" size="5cqmin">
{stat.number}
</Text>
</Stack>
</Tooltip>
</Grid.Col>
))}
</Grid>
<Text className="mediaRequests-stats-users-title" size="6.5cqmin">
{t("titles.users.main")}
</Text>
<Stack
className="mediaRequests-stats-users-wrapper"
flex={1}
w="100%"
ref={ref}
display="flex"
gap="2cqmin"
style={{ overflow: "hidden" }}
>
{users.slice(0, Math.max(Math.floor((height / width) * 5), 1)).map((user) => (
<Card
className={combineClasses(
"mediaRequests-stats-users-user-wrapper",
`mediaRequests-stats-users-user-${user.id}`,
)}
key={user.id}
withBorder
p="2cqmin"
flex={1}
mah="38.5cqmin"
radius="2.5cqmin"
>
<Group className="mediaRequests-stats-users-user-group" h="100%" p={0} gap="2cqmin" display="flex">
<Tooltip label={user.appName}>
<Avatar
className="mediaRequests-stats-users-user-avatar"
size="12.5cqmin"
src={user.avatar}
bd={`0.5cqmin solid ${user.appKind === "overseerr" ? "#ECB000" : "#6677CC"}`}
/>
</Tooltip>
<Stack className="mediaRequests-stats-users-user-infos" gap="2cqmin">
<Text className="mediaRequests-stats-users-user-userName" size="6cqmin">
{user.displayName}
</Text>
<Text className="mediaRequests-stats-users-user-request-count" size="4cqmin">
{tCommon("rtl", { value: t("titles.users.requests"), symbol: tCommon("symbols.colon") }) +
user.requestCount}
</Text>
</Stack>
<Space flex={1} />
<ActionIcon
className="mediaRequests-stats-users-user-link-button"
variant="light"
color="var(--mantine-color-text)"
size="10cqmin"
component="a"
href={user.link}
>
<IconExternalLink className="mediaRequests-stats-users-user-link-icon" size="7.5cqmin" />
</ActionIcon>
</Group>
</Card>
))}
</Stack>
</Stack>
);
}

View File

@@ -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"));

View File

@@ -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),
};
}

View File

@@ -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 (
<Stack align="center" justify="center" gap="0" w="100%" h="100%">
<Stack
align="center"
justify="center"
gap="0"
w="100%"
h="100%"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
{options.hasForecast ? (
<WeeklyForecast weather={weather} options={options} />
) : (
@@ -51,15 +58,15 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
<WeatherDescription weatherOnly weatherCode={weather.current.weathercode} />
</HoverCard.Dropdown>
</HoverCard>
<Text fz="20cqmin">{getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}</Text>
<Text fz="17.5cqmin">{getPreferredUnit(weather.current.temperature, options.isFormatFahrenheit)}</Text>
</Group>
<Space h="1cqmin" />
<Group className="weather-max-min-temp-group" wrap="nowrap" gap="1cqmin">
<IconArrowUpRight size="12.5cqmin" />
<Text fz="12.5cqmin">{getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit)}</Text>
<Text fz="10cqmin">{getPreferredUnit(weather.daily[0]?.maxTemp, options.isFormatFahrenheit)}</Text>
<Space w="2.5cqmin" />
<IconArrowDownRight size="12.5cqmin" />
<Text fz="12.5cqmin">{getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit)}</Text>
<Text fz="10cqmin">{getPreferredUnit(weather.daily[0]?.minTemp, options.isFormatFahrenheit)}</Text>
</Group>
{options.showCity && (
<>