mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat: add jellyfin integration (#672)
* feat: #655 implement jellyfin media server * fix: table overflow * feat: pr feedback * refactor: format * refactor: merge existing code * fix: code smells * refactor: format commit
This commit is contained in:
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -11,10 +11,11 @@
|
||||
"cSpell.words": [
|
||||
"cqmin",
|
||||
"homarr",
|
||||
"Sonarr",
|
||||
"jellyfin",
|
||||
"superjson",
|
||||
"trpc",
|
||||
"Umami"
|
||||
"Umami",
|
||||
"Sonarr"
|
||||
],
|
||||
"i18n-ally.dirStructure": "auto",
|
||||
"i18n-ally.enabledFrameworks": ["next-international"],
|
||||
|
||||
@@ -206,8 +206,8 @@ const ItemMenu = ({
|
||||
return (
|
||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="transparent" pos="absolute" top={offset} right={offset} style={{ zIndex: 1 }}>
|
||||
<IconDotsVertical />
|
||||
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
|
||||
<IconDotsVertical size={"1rem"} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { createItemWithIntegrationChannel } from "@homarr/redis";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
|
||||
import { createManyIntegrationOfOneItemMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
@@ -10,10 +10,8 @@ export const calendarRouter = createTRPCRouter({
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.flatMap(async (integration) => {
|
||||
for (const item of integration.items) {
|
||||
const cache = createItemWithIntegrationChannel<CalendarEvent[]>(item.itemId, integration.id);
|
||||
return await cache.getAsync();
|
||||
}
|
||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
|
||||
return await cache.getAsync();
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createTRPCRouter } from "../../trpc";
|
||||
import { appRouter } from "./app";
|
||||
import { calendarRouter } from "./calendar";
|
||||
import { dnsHoleRouter } from "./dns-hole";
|
||||
import { mediaServerRouter } from "./media-server";
|
||||
import { notebookRouter } from "./notebook";
|
||||
import { smartHomeRouter } from "./smart-home";
|
||||
import { weatherRouter } from "./weather";
|
||||
@@ -12,5 +13,6 @@ export const widgetRouter = createTRPCRouter({
|
||||
app: appRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
smartHome: smartHomeRouter,
|
||||
mediaServer: mediaServerRouter,
|
||||
calendar: calendarRouter,
|
||||
});
|
||||
|
||||
39
packages/api/src/router/widgets/media-server.ts
Normal file
39
packages/api/src/router/widgets/media-server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const mediaServerRouter = createTRPCRouter({
|
||||
getCurrentStreams: publicProcedure
|
||||
.unstable_concat(createManyIntegrationMiddleware("jellyfin", "plex"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const channel = createItemAndIntegrationChannel<StreamSession[]>("mediaServer", integration.id);
|
||||
const data = await channel.getAsync();
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
sessions: data?.data ?? [],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}),
|
||||
subscribeToCurrentStreams: publicProcedure
|
||||
.unstable_concat(createManyIntegrationMiddleware("jellyfin", "plex"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ integrationId: string; data: StreamSession[] }>((emit) => {
|
||||
for (const integration of ctx.integrations) {
|
||||
const channel = createItemAndIntegrationChannel<StreamSession[]>("mediaServer", integration.id);
|
||||
void channel.subscribeAsync((sessions) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
data: sessions,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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 { mediaServerJob } from "./jobs/integrations/media-server";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import { createCronJobGroup } from "./lib";
|
||||
|
||||
@@ -10,6 +11,7 @@ export const jobGroup = createCronJobGroup({
|
||||
iconsUpdater: iconsUpdaterJob,
|
||||
ping: pingJob,
|
||||
smartHomeEntityState: smartHomeEntityStateJob,
|
||||
mediaServer: mediaServerJob,
|
||||
mediaOrganizer: mediaOrganizerJob,
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { db, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema/sqlite";
|
||||
import { SonarrIntegration } from "@homarr/integrations";
|
||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||
import { createItemWithIntegrationChannel } from "@homarr/redis";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
|
||||
// This import is done that way to avoid circular dependencies.
|
||||
import type { WidgetComponentProps } from "../../../../widgets";
|
||||
@@ -50,7 +50,7 @@ export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).w
|
||||
});
|
||||
const events = await sonarr.getCalendarEventsAsync(start, end);
|
||||
|
||||
const cache = createItemWithIntegrationChannel<CalendarEvent[]>(itemForIntegration.id, integration.integrationId);
|
||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.integrationId);
|
||||
await cache.setAsync(events);
|
||||
}
|
||||
}
|
||||
|
||||
45
packages/cron-jobs/src/jobs/integrations/media-server.ts
Normal file
45
packages/cron-jobs/src/jobs/integrations/media-server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { decryptSecret } from "@homarr/common";
|
||||
import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema/sqlite";
|
||||
import { JellyfinIntegration } from "@homarr/integrations";
|
||||
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(async () => {
|
||||
const itemsForIntegration = await db.query.items.findMany({
|
||||
where: eq(items.kind, "mediaServer"),
|
||||
with: {
|
||||
integrations: {
|
||||
with: {
|
||||
integration: {
|
||||
with: {
|
||||
secrets: {
|
||||
columns: {
|
||||
kind: true,
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
for (const integration of itemForIntegration.integrations) {
|
||||
const jellyfinIntegration = new JellyfinIntegration({
|
||||
...integration.integration,
|
||||
decryptedSecrets: integration.integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
});
|
||||
const streamSessions = await jellyfinIntegration.getCurrentSessionsAsync();
|
||||
const channel = createItemAndIntegrationChannel("mediaServer", integration.integrationId);
|
||||
await channel.publishAndUpdateLastStateAsync(streamSessions);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,7 @@ export const widgetKinds = [
|
||||
"dnsHoleSummary",
|
||||
"smartHome-entityState",
|
||||
"smartHome-executeAutomation",
|
||||
"mediaServer",
|
||||
"calendar",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
@@ -23,10 +23,11 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@jellyfin/sdk": "^0.10.0",
|
||||
"@homarr/translation": "workspace:^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||
import type { IntegrationInput } from "./integration";
|
||||
@@ -11,6 +12,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int
|
||||
return new PiHoleIntegration(integration);
|
||||
case "homeAssistant":
|
||||
return new HomeAssistantIntegration(integration);
|
||||
case "jellyfin":
|
||||
return new JellyfinIntegration(integration);
|
||||
case "sonarr":
|
||||
return new SonarrIntegration(integration);
|
||||
default:
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// General integrations
|
||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||
|
||||
// Types
|
||||
export type { StreamSession } from "./interfaces/media-server/session";
|
||||
|
||||
// Helpers
|
||||
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
||||
export { integrationCreatorByKind } from "./base/creator";
|
||||
|
||||
17
packages/integrations/src/interfaces/media-server/session.ts
Normal file
17
packages/integrations/src/interfaces/media-server/session.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface StreamSession {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
user: {
|
||||
userId: string;
|
||||
username: string;
|
||||
profilePictureUrl: string | null;
|
||||
};
|
||||
currentlyPlaying: {
|
||||
type: "audio" | "video" | "tv" | "movie";
|
||||
name: string;
|
||||
seasonName: string | undefined;
|
||||
episodeName?: string | null;
|
||||
albumName?: string | null;
|
||||
episodeCount?: number | null;
|
||||
} | null;
|
||||
}
|
||||
68
packages/integrations/src/jellyfin/jellyfin-integration.ts
Normal file
68
packages/integrations/src/jellyfin/jellyfin-integration.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Jellyfin } from "@jellyfin/sdk";
|
||||
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
|
||||
|
||||
import { Integration } from "../base/integration";
|
||||
import type { StreamSession } from "../interfaces/media-server/session";
|
||||
|
||||
export class JellyfinIntegration extends Integration {
|
||||
private readonly jellyfin: Jellyfin = new Jellyfin({
|
||||
clientInfo: {
|
||||
name: "Homarr",
|
||||
version: "0.0.1",
|
||||
},
|
||||
deviceInfo: {
|
||||
name: "Homarr",
|
||||
id: "homarr",
|
||||
},
|
||||
});
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const api = this.getApi();
|
||||
const systemApi = getSystemApi(api);
|
||||
await systemApi.getPingSystem();
|
||||
}
|
||||
|
||||
public async getCurrentSessionsAsync(): Promise<StreamSession[]> {
|
||||
const api = this.getApi();
|
||||
const sessionApi = getSessionApi(api);
|
||||
const sessions = await sessionApi.getSessions();
|
||||
|
||||
if (sessions.status !== 200) {
|
||||
throw new Error(
|
||||
`Jellyfin server ${this.integration.url} returned a non successful status code: ${sessions.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return sessions.data.map((sessionInfo): StreamSession => {
|
||||
let nowPlaying: StreamSession["currentlyPlaying"] | null = null;
|
||||
|
||||
if (sessionInfo.NowPlayingItem) {
|
||||
nowPlaying = {
|
||||
type: "tv",
|
||||
name: sessionInfo.NowPlayingItem.Name ?? "",
|
||||
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
|
||||
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
|
||||
albumName: sessionInfo.NowPlayingItem.Album ?? "",
|
||||
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: `${sessionInfo.Id}`,
|
||||
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
|
||||
user: {
|
||||
profilePictureUrl: `${this.integration.url}/Users/${sessionInfo.UserId}/Images/Primary`,
|
||||
userId: sessionInfo.UserId ?? "",
|
||||
username: sessionInfo.UserName ?? "",
|
||||
},
|
||||
currentlyPlaying: nowPlaying,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getApi() {
|
||||
const apiKey = this.getSecretValue("apiKey");
|
||||
return this.jellyfin.createApi(this.integration.url, apiKey);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@
|
||||
"superjson": "2.2.1",
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/db": "workspace:^",
|
||||
"@homarr/common": "workspace:^"
|
||||
"@homarr/common": "workspace:^",
|
||||
"@homarr/definitions": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
|
||||
|
||||
export { createCacheChannel, createItemWithIntegrationChannel } from "./lib/channel";
|
||||
export { createCacheChannel, createItemAndIntegrationChannel } from "./lib/channel";
|
||||
|
||||
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
|
||||
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import superjson from "superjson";
|
||||
|
||||
import { createId } from "@homarr/db";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { createRedisConnection } from "./connection";
|
||||
@@ -168,8 +169,35 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
|
||||
};
|
||||
};
|
||||
|
||||
export const createItemWithIntegrationChannel = <T>(itemId: string, integrationId: string) =>
|
||||
createCacheChannel<T>(`item:${itemId}:integration:${integrationId}`);
|
||||
export const createItemAndIntegrationChannel = <TData>(kind: WidgetKind, integrationId: string) => {
|
||||
const channelName = `item:${kind}:integration:${integrationId}`;
|
||||
return {
|
||||
subscribeAsync: async (callback: (data: TData) => void) => {
|
||||
await subscriber.subscribe(channelName);
|
||||
subscriber.on("message", (channel, message) => {
|
||||
if (channel !== channelName) {
|
||||
logger.warn(`received message on ${channel} channel but was looking for ${channelName}`);
|
||||
return;
|
||||
}
|
||||
callback(superjson.parse(message));
|
||||
logger.debug(`sent message on ${channelName}`);
|
||||
});
|
||||
},
|
||||
publishAndUpdateLastStateAsync: async (data: TData) => {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
},
|
||||
setAsync: async (data: TData) => {
|
||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
},
|
||||
getAsync: async () => {
|
||||
const data = await getSetClient.get(channelName);
|
||||
if (!data) return null;
|
||||
|
||||
return superjson.parse<{ data: TData; timestamp: Date }>(data);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const queueClient = createRedisConnection();
|
||||
|
||||
|
||||
@@ -986,6 +986,7 @@ export default {
|
||||
},
|
||||
noIntegration: "No integration selected",
|
||||
},
|
||||
option: {},
|
||||
},
|
||||
video: {
|
||||
name: "Video Stream",
|
||||
@@ -1010,6 +1011,11 @@ export default {
|
||||
forYoutubeUseIframe: "For YouTube videos use the iframe option",
|
||||
},
|
||||
},
|
||||
mediaServer: {
|
||||
name: "Current media server streams",
|
||||
description: "Show the current streams on your media servers",
|
||||
option: {},
|
||||
},
|
||||
},
|
||||
widgetPreview: {
|
||||
toggle: {
|
||||
@@ -1485,6 +1491,9 @@ export default {
|
||||
ping: {
|
||||
label: "Pings",
|
||||
},
|
||||
mediaServer: {
|
||||
label: "Media Server",
|
||||
},
|
||||
mediaOrganizer: {
|
||||
label: "Media Organizers",
|
||||
},
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"next": "^14.2.4",
|
||||
"mantine-react-table": "2.0.0-beta.5",
|
||||
"react": "^18.3.1",
|
||||
"video.js": "^8.12.0"
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async function getServerDataAsync({ integrationIds, itemId }: Wid
|
||||
(
|
||||
item,
|
||||
): item is Exclude<Exclude<RouterOutputs["widget"]["calendar"]["findAllEvents"][number], null>, undefined> =>
|
||||
item !== null && item !== undefined,
|
||||
item !== null,
|
||||
)
|
||||
.flatMap((item) => item.data),
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { WidgetComponentProps } from "./definition";
|
||||
import * as dnsHoleSummary from "./dns-hole/summary";
|
||||
import * as iframe from "./iframe";
|
||||
import type { WidgetImportRecord } from "./import";
|
||||
import * as mediaServer from "./media-server";
|
||||
import * as notebook from "./notebook";
|
||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
||||
@@ -34,6 +35,7 @@ export const widgetImports = {
|
||||
dnsHoleSummary,
|
||||
"smartHome-entityState": smartHomeEntityState,
|
||||
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
||||
mediaServer,
|
||||
calendar,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
|
||||
124
packages/widgets/src/media-server/component.tsx
Normal file
124
packages/widgets/src/media-server/component.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Avatar, Box, Group, Text } from "@mantine/core";
|
||||
import { useListState } from "@mantine/hooks";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
|
||||
export default function MediaServerWidget({
|
||||
serverData,
|
||||
integrationIds,
|
||||
isEditMode,
|
||||
}: WidgetComponentProps<"mediaServer">) {
|
||||
const [currentStreams, currentStreamsHandlers] = useListState<{ integrationId: string; sessions: StreamSession[] }>(
|
||||
serverData?.initialData ?? [],
|
||||
);
|
||||
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "sessionName",
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
accessorKey: "user.username",
|
||||
header: "User",
|
||||
Cell: ({ row }) => (
|
||||
<Group gap={"xs"}>
|
||||
<Avatar src={row.original.user.profilePictureUrl} size={"sm"} />
|
||||
<Text>{row.original.user.username}</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "currentlyPlaying", // currentlyPlaying.name can be undefined which results in a warning. This is why we use currentlyPlaying instead of currentlyPlaying.name
|
||||
header: "Currently playing",
|
||||
Cell: ({ row }) => {
|
||||
if (row.original.currentlyPlaying) {
|
||||
return (
|
||||
<div>
|
||||
<span>{row.original.currentlyPlaying.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
clientApi.widget.mediaServer.subscribeToCurrentStreams.useSubscription(
|
||||
{
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
enabled: !isEditMode,
|
||||
onData(data) {
|
||||
currentStreamsHandlers.applyWhere(
|
||||
(pair) => pair.integrationId === data.integrationId,
|
||||
(pair) => {
|
||||
return {
|
||||
...pair,
|
||||
sessions: data.data,
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Only render the flat list of sessions when the currentStreams change
|
||||
// Otherwise it will always create a new array reference and cause the table to re-render
|
||||
const flatSessions = useMemo(() => currentStreams.flatMap((pair) => pair.sessions), [currentStreams]);
|
||||
|
||||
const table = useMantineReactTable({
|
||||
columns,
|
||||
data: flatSessions,
|
||||
enableRowSelection: false,
|
||||
enableColumnOrdering: false,
|
||||
enableFullScreenToggle: false,
|
||||
enableGlobalFilter: false,
|
||||
enableDensityToggle: false,
|
||||
enableFilters: false,
|
||||
enablePagination: true,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
enableTopToolbar: false,
|
||||
enableColumnActions: false,
|
||||
enableStickyHeader: true,
|
||||
initialState: {
|
||||
density: "xs",
|
||||
},
|
||||
mantinePaperProps: {
|
||||
display: "flex",
|
||||
h: "100%",
|
||||
withBorder: false,
|
||||
style: {
|
||||
flexDirection: "column",
|
||||
},
|
||||
},
|
||||
mantineTableProps: {
|
||||
style: {
|
||||
tableLayout: "fixed",
|
||||
},
|
||||
},
|
||||
mantineTableContainerProps: {
|
||||
style: {
|
||||
flexGrow: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box h="100%">
|
||||
<MantineReactTable table={table} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
11
packages/widgets/src/media-server/index.ts
Normal file
11
packages/widgets/src/media-server/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IconVideo } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
|
||||
export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaServer", {
|
||||
icon: IconVideo,
|
||||
options: {},
|
||||
supportedIntegrations: ["jellyfin"],
|
||||
})
|
||||
.withServerData(() => import("./serverData"))
|
||||
.withDynamicImport(() => import("./component"));
|
||||
21
packages/widgets/src/media-server/serverData.ts
Normal file
21
packages/widgets/src/media-server/serverData.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use server";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import type { WidgetProps } from "../definition";
|
||||
|
||||
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"mediaServer">) {
|
||||
if (integrationIds.length === 0) {
|
||||
return {
|
||||
initialData: [],
|
||||
};
|
||||
}
|
||||
|
||||
const currentStreams = await api.widget.mediaServer.getCurrentStreams({
|
||||
integrationIds,
|
||||
});
|
||||
|
||||
return {
|
||||
initialData: currentStreams,
|
||||
};
|
||||
}
|
||||
40
pnpm-lock.yaml
generated
40
pnpm-lock.yaml
generated
@@ -890,6 +890,9 @@ importers:
|
||||
'@homarr/validation':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../validation
|
||||
'@jellyfin/sdk':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0(axios@1.7.2)
|
||||
devDependencies:
|
||||
'@homarr/eslint-config':
|
||||
specifier: workspace:^0.2.0
|
||||
@@ -1030,6 +1033,9 @@ importers:
|
||||
'@homarr/db':
|
||||
specifier: workspace:^
|
||||
version: link:../db
|
||||
'@homarr/definitions':
|
||||
specifier: workspace:^
|
||||
version: link:../definitions
|
||||
'@homarr/log':
|
||||
specifier: workspace:^
|
||||
version: link:../log
|
||||
@@ -1320,6 +1326,9 @@ importers:
|
||||
dayjs:
|
||||
specifier: ^1.11.11
|
||||
version: 1.11.11
|
||||
mantine-react-table:
|
||||
specifier: 2.0.0-beta.5
|
||||
version: 2.0.0-beta.5(@mantine/core@7.11.1(@mantine/hooks@7.11.1(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/dates@7.11.1(@mantine/core@7.11.1(@mantine/hooks@7.11.1(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.11.1(react@18.3.1))(dayjs@1.11.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.11.1(react@18.3.1))(@tabler/icons-react@3.8.0(react@18.3.1))(clsx@2.1.1)(dayjs@1.11.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
next:
|
||||
specifier: ^14.2.4
|
||||
version: 14.2.4(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.6)
|
||||
@@ -2089,6 +2098,11 @@ packages:
|
||||
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@jellyfin/sdk@0.10.0':
|
||||
resolution: {integrity: sha512-fUUwiPOGQEFYxnS9olYkv7GXIX5N9JYdRBR8bapN86OhbHWzL1JHgWf/sAUcNTQGlCWMKTJqve4KFOQB1FlMAQ==}
|
||||
peerDependencies:
|
||||
axios: ^1.3.4
|
||||
|
||||
'@jest/schemas@29.6.3':
|
||||
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
@@ -3218,6 +3232,9 @@ packages:
|
||||
resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
axios@1.7.2:
|
||||
resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==}
|
||||
|
||||
axobject-query@3.1.1:
|
||||
resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==}
|
||||
|
||||
@@ -4122,6 +4139,15 @@ packages:
|
||||
fn.name@1.1.0:
|
||||
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
|
||||
|
||||
follow-redirects@1.15.6:
|
||||
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
for-each@0.3.3:
|
||||
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
|
||||
|
||||
@@ -7150,6 +7176,10 @@ snapshots:
|
||||
|
||||
'@istanbuljs/schema@0.1.3': {}
|
||||
|
||||
'@jellyfin/sdk@0.10.0(axios@1.7.2)':
|
||||
dependencies:
|
||||
axios: 1.7.2
|
||||
|
||||
'@jest/schemas@29.6.3':
|
||||
dependencies:
|
||||
'@sinclair/typebox': 0.27.8
|
||||
@@ -8442,6 +8472,14 @@ snapshots:
|
||||
|
||||
axe-core@4.9.1: {}
|
||||
|
||||
axios@1.7.2:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.6
|
||||
form-data: 4.0.0
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
axobject-query@3.1.1:
|
||||
dependencies:
|
||||
deep-equal: 2.2.3
|
||||
@@ -9559,6 +9597,8 @@ snapshots:
|
||||
|
||||
fn.name@1.1.0: {}
|
||||
|
||||
follow-redirects@1.15.6: {}
|
||||
|
||||
for-each@0.3.3:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
Reference in New Issue
Block a user