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 1, 2024
1 parent 4e1bbf2 commit 61acf82
Show file tree
Hide file tree
Showing 22 changed files with 469 additions and 14 deletions.
9 changes: 8 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@
"typescript.tsdk": "node_modules\\typescript\\lib",
"js/ts.implicitProjectConfig.experimentalDecorators": true,
"prettier.configPath": "./tooling/prettier/index.mjs",
"cSpell.words": ["cqmin", "homarr", "superjson", "trpc", "Umami"],
"cSpell.words": [
"cqmin",
"homarr",
"jellyfin",
"superjson",
"trpc",
"Umami"
],
"i18n-ally.dirStructure": "auto",
"i18n-ally.enabledFrameworks": ["next-international"],
"i18n-ally.localesPaths": ["./packages/translation/src/lang/"],
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 apps/tasks/src/jobs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { iconsUpdaterJob } from "~/jobs/icons-updater";
import { smartHomeEntityStateJob } from "~/jobs/integrations/home-assistant";
import { analyticsJob } from "./jobs/analytics";
import { mediaServerJob } from "./jobs/integrations/media-server";
import { pingJob } from "./jobs/ping";
import { queuesJob } from "./jobs/queue";
import { createCronJobGroup } from "./lib/jobs";
Expand All @@ -11,6 +12,7 @@ export const jobs = createCronJobGroup({
iconsUpdater: iconsUpdaterJob,
ping: pingJob,
smartHomeEntityState: smartHomeEntityStateJob,
mediaServer: mediaServerJob,

// This job is used to process queues.
queues: queuesJob,
Expand Down
45 changes: 45 additions & 0 deletions apps/tasks/src/jobs/integrations/media-server.ts
Original file line number Diff line number Diff line change
@@ -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/jobs";

export const mediaServerJob = createCronJob("media-server", 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);
}
}
});
2 changes: 2 additions & 0 deletions packages/api/src/router/widgets/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createTRPCRouter } from "../../trpc";
import { appRouter } from "./app";
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 @@ -11,4 +12,5 @@ export const widgetRouter = createTRPCRouter({
app: appRouter,
dnsHole: dnsHoleRouter,
smartHome: smartHomeRouter,
mediaServer: mediaServerRouter,
});
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,
});
});
}
});
}),
});
1 change: 1 addition & 0 deletions packages/definitions/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export const widgetKinds = [
"dnsHoleSummary",
"smartHome-entityState",
"smartHome-executeAutomation",
"mediaServer",
] 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 { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
import type { IntegrationInput } from "./integration";

Expand All @@ -10,6 +11,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int
return new PiHoleIntegration(integration);
case "homeAssistant":
return new HomeAssistantIntegration(integration);
case "jellyfin":
return new JellyfinIntegration(integration);
default:
throw new Error(`Unknown integration kind ${kind}`);
}
Expand Down
4 changes: 4 additions & 0 deletions packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// General integrations
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";

// Types
export type { StreamSession } from "./interfaces/media-server/session";

// Helpers
export { IntegrationTestConnectionError } from "./base/test-connection-error";
Expand Down
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 } 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 }>(
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 @@ -160,6 +161,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);
},
};
};

const queueClient = createRedisConnection();

type WithId<TItem> = TItem & { _id: string };
Expand Down
6 changes: 6 additions & 0 deletions packages/translation/src/lang/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,7 @@ export default {
},
noIntegration: "No integration selected",
},
option: {},
},
video: {
name: "Video Stream",
Expand All @@ -998,6 +999,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
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 @@ -11,6 +11,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 @@ -33,6 +34,7 @@ export const widgetImports = {
dnsHoleSummary,
"smartHome-entityState": smartHomeEntityState,
"smartHome-executeAutomation": smartHomeExecuteAutomation,
mediaServer,
} satisfies WidgetImportRecord;

export type WidgetImports = typeof widgetImports;
Expand Down
Loading

0 comments on commit 61acf82

Please sign in to comment.