Skip to content

Commit

Permalink
feat: #655 implement jellyfin media server
Browse files Browse the repository at this point in the history
  • Loading branch information
manuel-rw committed Jul 2, 2024
1 parent 6aecd1a commit 0a599b7
Show file tree
Hide file tree
Showing 22 changed files with 419 additions and 6 deletions.
5 changes: 3 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
"cSpell.words": [
"cqmin",
"homarr",
"Sonarr",
"jellyfin",
"superjson",
"trpc",
"Umami"
"Umami",
"Sonarr"
],
"i18n-ally.dirStructure": "auto",
"i18n-ally.enabledFrameworks": ["next-international"],
Expand Down
2 changes: 1 addition & 1 deletion apps/nextjs/src/components/board/sections/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ 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 }}>
<ActionIcon variant="transparent" pos="absolute" top={offset} right={offset} style={{ zIndex: 2 }}>
<IconDotsVertical />
</ActionIcon>
</Menu.Target>
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/router/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createTRPCRouter } from "../../trpc";
import { appRouter } from "./app";
import { calendarRouter } from "./calendar";
import { dnsHoleRouter } from "./dns-hole";
import { mediaServerRouter } from "./media-server";
import { notebookRouter } from "./notebook";
import { smartHomeRouter } from "./smart-home";
import { weatherRouter } from "./weather";
Expand All @@ -12,5 +13,6 @@ export const widgetRouter = createTRPCRouter({
app: appRouter,
dnsHole: dnsHoleRouter,
smartHome: smartHomeRouter,
mediaServer: mediaServerRouter,
calendar: calendarRouter,
});
39 changes: 39 additions & 0 deletions packages/api/src/router/widgets/media-server.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/cron-jobs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
import { pingJob } from "./jobs/ping";
import { createCronJobGroup } from "./lib";
import { mediaServerJob } from "./jobs/integrations/media-server";

export const jobGroup = createCronJobGroup({
analytics: analyticsJob,
iconsUpdater: iconsUpdaterJob,
ping: pingJob,
smartHomeEntityState: smartHomeEntityStateJob,
mediaServer: mediaServerJob,
mediaOrganizer: mediaOrganizerJob,
});

Expand Down
44 changes: 44 additions & 0 deletions packages/cron-jobs/src/jobs/integrations/media-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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);
}
}
});
1 change: 1 addition & 0 deletions packages/definitions/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const widgetKinds = [
"dnsHoleSummary",
"smartHome-entityState",
"smartHome-executeAutomation",
"mediaServer",
"calendar",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
3 changes: 2 additions & 1 deletion packages/integrations/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions packages/integrations/src/base/creator.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 17 additions & 0 deletions packages/integrations/src/interfaces/media-server/session.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 68 additions & 0 deletions packages/integrations/src/jellyfin/jellyfin-integration.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
3 changes: 2 additions & 1 deletion packages/redis/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/redis/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";

export { createCacheChannel, createItemWithIntegrationChannel } from "./lib/channel";
export { createCacheChannel, createItemAndIntegrationChannel, createItemWithIntegrationChannel } from "./lib/channel";

export const exampleChannel = createSubPubChannel<{ message: string }>("example");
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(
Expand Down
28 changes: 28 additions & 0 deletions packages/redis/src/lib/channel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import superjson from "superjson";

import { createId } from "@homarr/db";
import { WidgetKind } from "@homarr/definitions";
import { logger } from "@homarr/log";

import { createRedisConnection } from "./connection";
Expand Down Expand Up @@ -168,6 +169,33 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
};
};

export const createItemAndIntegrationChannel = <TData>(kind: WidgetKind, integrationId: string) => {
const channelName = `item:${kind}:${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 + " but was looking for " + channelName);
return;
}
callback(superjson.parse(message));
logger.info("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() }));
},
getAsync: async () => {
const data = await getSetClient.get(channelName);
if (!data) return null;

return superjson.parse<{ data: TData; timestamp: Date }>(data);
},
};
};

export const createItemWithIntegrationChannel = <T>(itemId: string, integrationId: string) =>
createCacheChannel<T>(`item:${itemId}:integration:${integrationId}`);

Expand Down
9 changes: 9 additions & 0 deletions packages/translation/src/lang/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,7 @@ export default {
},
noIntegration: "No integration selected",
},
option: {},
},
video: {
name: "Video Stream",
Expand All @@ -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: {
Expand Down Expand Up @@ -1485,6 +1491,9 @@ export default {
ping: {
label: "Pings",
},
mediaServer: {
label: "Media Server"
},
mediaOrganizer: {
label: "Media Organizers",
},
Expand Down
1 change: 1 addition & 0 deletions packages/widgets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 2 additions & 0 deletions packages/widgets/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -34,6 +35,7 @@ export const widgetImports = {
dnsHoleSummary,
"smartHome-entityState": smartHomeEntityState,
"smartHome-executeAutomation": smartHomeExecuteAutomation,
mediaServer,
calendar,
} satisfies WidgetImportRecord;

Expand Down
Loading

0 comments on commit 0a599b7

Please sign in to comment.