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": [
|
"cSpell.words": [
|
||||||
"cqmin",
|
"cqmin",
|
||||||
"homarr",
|
"homarr",
|
||||||
"Sonarr",
|
"jellyfin",
|
||||||
"superjson",
|
"superjson",
|
||||||
"trpc",
|
"trpc",
|
||||||
"Umami"
|
"Umami",
|
||||||
|
"Sonarr"
|
||||||
],
|
],
|
||||||
"i18n-ally.dirStructure": "auto",
|
"i18n-ally.dirStructure": "auto",
|
||||||
"i18n-ally.enabledFrameworks": ["next-international"],
|
"i18n-ally.enabledFrameworks": ["next-international"],
|
||||||
|
|||||||
@@ -206,8 +206,8 @@ const ItemMenu = ({
|
|||||||
return (
|
return (
|
||||||
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon variant="transparent" pos="absolute" top={offset} right={offset} style={{ zIndex: 1 }}>
|
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
|
||||||
<IconDotsVertical />
|
<IconDotsVertical size={"1rem"} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown miw={128}>
|
<Menu.Dropdown miw={128}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||||
import { createItemWithIntegrationChannel } from "@homarr/redis";
|
import { createItemAndIntegrationChannel } from "@homarr/redis";
|
||||||
|
|
||||||
import { createManyIntegrationOfOneItemMiddleware } from "../../middlewares/integration";
|
import { createManyIntegrationOfOneItemMiddleware } from "../../middlewares/integration";
|
||||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
@@ -10,10 +10,8 @@ export const calendarRouter = createTRPCRouter({
|
|||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
ctx.integrations.flatMap(async (integration) => {
|
ctx.integrations.flatMap(async (integration) => {
|
||||||
for (const item of integration.items) {
|
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
|
||||||
const cache = createItemWithIntegrationChannel<CalendarEvent[]>(item.itemId, integration.id);
|
return await cache.getAsync();
|
||||||
return await cache.getAsync();
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createTRPCRouter } from "../../trpc";
|
|||||||
import { appRouter } from "./app";
|
import { appRouter } from "./app";
|
||||||
import { calendarRouter } from "./calendar";
|
import { calendarRouter } from "./calendar";
|
||||||
import { dnsHoleRouter } from "./dns-hole";
|
import { dnsHoleRouter } from "./dns-hole";
|
||||||
|
import { mediaServerRouter } from "./media-server";
|
||||||
import { notebookRouter } from "./notebook";
|
import { notebookRouter } from "./notebook";
|
||||||
import { smartHomeRouter } from "./smart-home";
|
import { smartHomeRouter } from "./smart-home";
|
||||||
import { weatherRouter } from "./weather";
|
import { weatherRouter } from "./weather";
|
||||||
@@ -12,5 +13,6 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
app: appRouter,
|
app: appRouter,
|
||||||
dnsHole: dnsHoleRouter,
|
dnsHole: dnsHoleRouter,
|
||||||
smartHome: smartHomeRouter,
|
smartHome: smartHomeRouter,
|
||||||
|
mediaServer: mediaServerRouter,
|
||||||
calendar: calendarRouter,
|
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 { iconsUpdaterJob } from "./jobs/icons-updater";
|
||||||
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
|
||||||
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
|
||||||
|
import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||||
import { pingJob } from "./jobs/ping";
|
import { pingJob } from "./jobs/ping";
|
||||||
import { createCronJobGroup } from "./lib";
|
import { createCronJobGroup } from "./lib";
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ export const jobGroup = createCronJobGroup({
|
|||||||
iconsUpdater: iconsUpdaterJob,
|
iconsUpdater: iconsUpdaterJob,
|
||||||
ping: pingJob,
|
ping: pingJob,
|
||||||
smartHomeEntityState: smartHomeEntityStateJob,
|
smartHomeEntityState: smartHomeEntityStateJob,
|
||||||
|
mediaServer: mediaServerJob,
|
||||||
mediaOrganizer: mediaOrganizerJob,
|
mediaOrganizer: mediaOrganizerJob,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { db, eq } from "@homarr/db";
|
|||||||
import { items } from "@homarr/db/schema/sqlite";
|
import { items } from "@homarr/db/schema/sqlite";
|
||||||
import { SonarrIntegration } from "@homarr/integrations";
|
import { SonarrIntegration } from "@homarr/integrations";
|
||||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
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.
|
// This import is done that way to avoid circular dependencies.
|
||||||
import type { WidgetComponentProps } from "../../../../widgets";
|
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 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);
|
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",
|
"dnsHoleSummary",
|
||||||
"smartHome-entityState",
|
"smartHome-entityState",
|
||||||
"smartHome-executeAutomation",
|
"smartHome-executeAutomation",
|
||||||
|
"mediaServer",
|
||||||
"calendar",
|
"calendar",
|
||||||
] as const;
|
] as const;
|
||||||
export type WidgetKind = (typeof widgetKinds)[number];
|
export type WidgetKind = (typeof widgetKinds)[number];
|
||||||
|
|||||||
@@ -23,10 +23,11 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "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"
|
"@homarr/translation": "workspace:^0.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
|
|
||||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||||
|
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||||
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
|
||||||
import type { IntegrationInput } from "./integration";
|
import type { IntegrationInput } from "./integration";
|
||||||
@@ -11,6 +12,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int
|
|||||||
return new PiHoleIntegration(integration);
|
return new PiHoleIntegration(integration);
|
||||||
case "homeAssistant":
|
case "homeAssistant":
|
||||||
return new HomeAssistantIntegration(integration);
|
return new HomeAssistantIntegration(integration);
|
||||||
|
case "jellyfin":
|
||||||
|
return new JellyfinIntegration(integration);
|
||||||
case "sonarr":
|
case "sonarr":
|
||||||
return new SonarrIntegration(integration);
|
return new SonarrIntegration(integration);
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
// General integrations
|
// General integrations
|
||||||
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
|
||||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||||
|
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type { StreamSession } from "./interfaces/media-server/session";
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
export { IntegrationTestConnectionError } from "./base/test-connection-error";
|
||||||
export { integrationCreatorByKind } from "./base/creator";
|
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",
|
"superjson": "2.2.1",
|
||||||
"@homarr/log": "workspace:^",
|
"@homarr/log": "workspace:^",
|
||||||
"@homarr/db": "workspace:^",
|
"@homarr/db": "workspace:^",
|
||||||
"@homarr/common": "workspace:^"
|
"@homarr/common": "workspace:^",
|
||||||
|
"@homarr/definitions": "workspace:^"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
|
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 exampleChannel = createSubPubChannel<{ message: string }>("example");
|
||||||
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(
|
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
import { createId } from "@homarr/db";
|
import { createId } from "@homarr/db";
|
||||||
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
import { createRedisConnection } from "./connection";
|
import { createRedisConnection } from "./connection";
|
||||||
@@ -168,8 +169,35 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createItemWithIntegrationChannel = <T>(itemId: string, integrationId: string) =>
|
export const createItemAndIntegrationChannel = <TData>(kind: WidgetKind, integrationId: string) => {
|
||||||
createCacheChannel<T>(`item:${itemId}:integration:${integrationId}`);
|
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();
|
const queueClient = createRedisConnection();
|
||||||
|
|
||||||
|
|||||||
@@ -986,6 +986,7 @@ export default {
|
|||||||
},
|
},
|
||||||
noIntegration: "No integration selected",
|
noIntegration: "No integration selected",
|
||||||
},
|
},
|
||||||
|
option: {},
|
||||||
},
|
},
|
||||||
video: {
|
video: {
|
||||||
name: "Video Stream",
|
name: "Video Stream",
|
||||||
@@ -1010,6 +1011,11 @@ export default {
|
|||||||
forYoutubeUseIframe: "For YouTube videos use the iframe option",
|
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: {
|
widgetPreview: {
|
||||||
toggle: {
|
toggle: {
|
||||||
@@ -1485,6 +1491,9 @@ export default {
|
|||||||
ping: {
|
ping: {
|
||||||
label: "Pings",
|
label: "Pings",
|
||||||
},
|
},
|
||||||
|
mediaServer: {
|
||||||
|
label: "Media Server",
|
||||||
|
},
|
||||||
mediaOrganizer: {
|
mediaOrganizer: {
|
||||||
label: "Media Organizers",
|
label: "Media Organizers",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"next": "^14.2.4",
|
"next": "^14.2.4",
|
||||||
|
"mantine-react-table": "2.0.0-beta.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"video.js": "^8.12.0"
|
"video.js": "^8.12.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default async function getServerDataAsync({ integrationIds, itemId }: Wid
|
|||||||
(
|
(
|
||||||
item,
|
item,
|
||||||
): item is Exclude<Exclude<RouterOutputs["widget"]["calendar"]["findAllEvents"][number], null>, undefined> =>
|
): item is Exclude<Exclude<RouterOutputs["widget"]["calendar"]["findAllEvents"][number], null>, undefined> =>
|
||||||
item !== null && item !== undefined,
|
item !== null,
|
||||||
)
|
)
|
||||||
.flatMap((item) => item.data),
|
.flatMap((item) => item.data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { WidgetComponentProps } from "./definition";
|
|||||||
import * as dnsHoleSummary from "./dns-hole/summary";
|
import * as dnsHoleSummary from "./dns-hole/summary";
|
||||||
import * as iframe from "./iframe";
|
import * as iframe from "./iframe";
|
||||||
import type { WidgetImportRecord } from "./import";
|
import type { WidgetImportRecord } from "./import";
|
||||||
|
import * as mediaServer from "./media-server";
|
||||||
import * as notebook from "./notebook";
|
import * as notebook from "./notebook";
|
||||||
import * as smartHomeEntityState from "./smart-home/entity-state";
|
import * as smartHomeEntityState from "./smart-home/entity-state";
|
||||||
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
|
||||||
@@ -34,6 +35,7 @@ export const widgetImports = {
|
|||||||
dnsHoleSummary,
|
dnsHoleSummary,
|
||||||
"smartHome-entityState": smartHomeEntityState,
|
"smartHome-entityState": smartHomeEntityState,
|
||||||
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
"smartHome-executeAutomation": smartHomeExecuteAutomation,
|
||||||
|
mediaServer,
|
||||||
calendar,
|
calendar,
|
||||||
} satisfies WidgetImportRecord;
|
} 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':
|
'@homarr/validation':
|
||||||
specifier: workspace:^0.1.0
|
specifier: workspace:^0.1.0
|
||||||
version: link:../validation
|
version: link:../validation
|
||||||
|
'@jellyfin/sdk':
|
||||||
|
specifier: ^0.10.0
|
||||||
|
version: 0.10.0(axios@1.7.2)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@homarr/eslint-config':
|
'@homarr/eslint-config':
|
||||||
specifier: workspace:^0.2.0
|
specifier: workspace:^0.2.0
|
||||||
@@ -1030,6 +1033,9 @@ importers:
|
|||||||
'@homarr/db':
|
'@homarr/db':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../db
|
version: link:../db
|
||||||
|
'@homarr/definitions':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../definitions
|
||||||
'@homarr/log':
|
'@homarr/log':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../log
|
version: link:../log
|
||||||
@@ -1320,6 +1326,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.11
|
specifier: ^1.11.11
|
||||||
version: 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:
|
next:
|
||||||
specifier: ^14.2.4
|
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)
|
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==}
|
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
'@jellyfin/sdk@0.10.0':
|
||||||
|
resolution: {integrity: sha512-fUUwiPOGQEFYxnS9olYkv7GXIX5N9JYdRBR8bapN86OhbHWzL1JHgWf/sAUcNTQGlCWMKTJqve4KFOQB1FlMAQ==}
|
||||||
|
peerDependencies:
|
||||||
|
axios: ^1.3.4
|
||||||
|
|
||||||
'@jest/schemas@29.6.3':
|
'@jest/schemas@29.6.3':
|
||||||
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
@@ -3218,6 +3232,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==}
|
resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
axios@1.7.2:
|
||||||
|
resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==}
|
||||||
|
|
||||||
axobject-query@3.1.1:
|
axobject-query@3.1.1:
|
||||||
resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==}
|
resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==}
|
||||||
|
|
||||||
@@ -4122,6 +4139,15 @@ packages:
|
|||||||
fn.name@1.1.0:
|
fn.name@1.1.0:
|
||||||
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
|
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:
|
for-each@0.3.3:
|
||||||
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
|
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
|
||||||
|
|
||||||
@@ -7150,6 +7176,10 @@ snapshots:
|
|||||||
|
|
||||||
'@istanbuljs/schema@0.1.3': {}
|
'@istanbuljs/schema@0.1.3': {}
|
||||||
|
|
||||||
|
'@jellyfin/sdk@0.10.0(axios@1.7.2)':
|
||||||
|
dependencies:
|
||||||
|
axios: 1.7.2
|
||||||
|
|
||||||
'@jest/schemas@29.6.3':
|
'@jest/schemas@29.6.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sinclair/typebox': 0.27.8
|
'@sinclair/typebox': 0.27.8
|
||||||
@@ -8442,6 +8472,14 @@ snapshots:
|
|||||||
|
|
||||||
axe-core@4.9.1: {}
|
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:
|
axobject-query@3.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-equal: 2.2.3
|
deep-equal: 2.2.3
|
||||||
@@ -9559,6 +9597,8 @@ snapshots:
|
|||||||
|
|
||||||
fn.name@1.1.0: {}
|
fn.name@1.1.0: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.6: {}
|
||||||
|
|
||||||
for-each@0.3.3:
|
for-each@0.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
|
|||||||
Reference in New Issue
Block a user