From 61acf82f874476e55296d87597a12329f312e8c1 Mon Sep 17 00:00:00 2001 From: Manuel Date: Mon, 1 Jul 2024 19:07:53 +0200 Subject: [PATCH] feat: #655 implement jellyfin media server --- .vscode/settings.json | 9 +- .../src/components/board/sections/content.tsx | 2 +- apps/tasks/src/jobs.ts | 2 + .../src/jobs/integrations/media-server.ts | 45 +++++++ packages/api/src/router/widgets/index.ts | 2 + .../api/src/router/widgets/media-server.ts | 39 ++++++ packages/definitions/src/widget.ts | 1 + packages/integrations/package.json | 3 +- packages/integrations/src/base/creator.ts | 3 + packages/integrations/src/index.ts | 4 + .../src/interfaces/media-server/session.ts | 17 +++ .../src/jellyfin/jellyfin-integration.ts | 68 ++++++++++ packages/redis/package.json | 3 +- packages/redis/src/index.ts | 2 +- packages/redis/src/lib/channel.ts | 28 +++++ packages/translation/src/lang/en.ts | 6 + packages/widgets/package.json | 1 + packages/widgets/src/index.tsx | 2 + .../widgets/src/media-server/component.tsx | 118 ++++++++++++++++++ packages/widgets/src/media-server/index.ts | 11 ++ .../widgets/src/media-server/serverData.ts | 21 ++++ pnpm-lock.yaml | 96 ++++++++++++-- 22 files changed, 469 insertions(+), 14 deletions(-) create mode 100644 apps/tasks/src/jobs/integrations/media-server.ts create mode 100644 packages/api/src/router/widgets/media-server.ts create mode 100644 packages/integrations/src/interfaces/media-server/session.ts create mode 100644 packages/integrations/src/jellyfin/jellyfin-integration.ts create mode 100644 packages/widgets/src/media-server/component.tsx create mode 100644 packages/widgets/src/media-server/index.ts create mode 100644 packages/widgets/src/media-server/serverData.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 94ed7a3fc9..452db1b8f4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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/"], diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index 96ceaed995..d0bc249897 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -206,7 +206,7 @@ const ItemMenu = ({ return ( - + diff --git a/apps/tasks/src/jobs.ts b/apps/tasks/src/jobs.ts index 88cbb3ac3f..73638e6bd2 100644 --- a/apps/tasks/src/jobs.ts +++ b/apps/tasks/src/jobs.ts @@ -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"; @@ -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, diff --git a/apps/tasks/src/jobs/integrations/media-server.ts b/apps/tasks/src/jobs/integrations/media-server.ts new file mode 100644 index 0000000000..353023ee5b --- /dev/null +++ b/apps/tasks/src/jobs/integrations/media-server.ts @@ -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); + } + } +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 7ece7bd623..647fc29cb6 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -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"; @@ -11,4 +12,5 @@ export const widgetRouter = createTRPCRouter({ app: appRouter, dnsHole: dnsHoleRouter, smartHome: smartHomeRouter, + mediaServer: mediaServerRouter, }); diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts new file mode 100644 index 0000000000..da43e8d203 --- /dev/null +++ b/packages/api/src/router/widgets/media-server.ts @@ -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("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("mediaServer", integration.id); + void channel.subscribeAsync((sessions) => { + emit.next({ + integrationId: integration.id, + data: sessions, + }); + }); + } + }); + }), +}); diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index d36b936876..ac0317094b 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -8,5 +8,6 @@ export const widgetKinds = [ "dnsHoleSummary", "smartHome-entityState", "smartHome-executeAutomation", + "mediaServer", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/package.json b/packages/integrations/package.json index ec31ba1127..16d1fe9f7b 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -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": { diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 50aee2be88..a14aabed5e 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -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"; @@ -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}`); } diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index fd241bae3b..f36b7aa584 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -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"; diff --git a/packages/integrations/src/interfaces/media-server/session.ts b/packages/integrations/src/interfaces/media-server/session.ts new file mode 100644 index 0000000000..ef58c77a6f --- /dev/null +++ b/packages/integrations/src/interfaces/media-server/session.ts @@ -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; +} diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts new file mode 100644 index 0000000000..3a9f22b6ae --- /dev/null +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -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 { + const api = this.getApi(); + const systemApi = getSystemApi(api); + await systemApi.getPingSystem(); + } + + public async getCurrentSessionsAsync(): Promise { + 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); + } +} diff --git a/packages/redis/package.json b/packages/redis/package.json index 97f559ec63..980e0ed21e 100644 --- a/packages/redis/package.json +++ b/packages/redis/package.json @@ -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", diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 78300c3cf4..f9c1480d39 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -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 }>( diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 7a59e6fd77..b0fa9d4525 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -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"; @@ -160,6 +161,33 @@ export const createCacheChannel = (name: string, cacheDurationMs: number }; }; +export const createItemAndIntegrationChannel = (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 & { _id: string }; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 98071e1287..498b7199b5 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -974,6 +974,7 @@ export default { }, noIntegration: "No integration selected", }, + option: {}, }, video: { name: "Video Stream", @@ -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: { diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 0bffd4a685..23e7c08099 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -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" }, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 3049fae66f..647996362c 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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"; @@ -33,6 +34,7 @@ export const widgetImports = { dnsHoleSummary, "smartHome-entityState": smartHomeEntityState, "smartHome-executeAutomation": smartHomeExecuteAutomation, + mediaServer, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx new file mode 100644 index 0000000000..fabb14518f --- /dev/null +++ b/packages/widgets/src/media-server/component.tsx @@ -0,0 +1,118 @@ +"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[]>( + () => [ + { + accessorKey: "sessionName", + header: "Name", + }, + { + accessorKey: "user.username", + header: "User", + Cell: ({ row }) => ( + + + {row.original.user.username} + + ), + }, + { + 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 ( +
+ {row.original.currentlyPlaying.name} +
+ ); + } + + 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, + initialState: { + density: "xs", + }, + mantinePaperProps: { + display: "flex", + h: "100%", + withBorder: false, + style: { + flexDirection: "column", + }, + }, + mantineTableContainerProps: { + style: { + flexGrow: 5, + }, + }, + }); + + return ( + + + + ); +} diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts new file mode 100644 index 0000000000..f5efac742c --- /dev/null +++ b/packages/widgets/src/media-server/index.ts @@ -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")); diff --git a/packages/widgets/src/media-server/serverData.ts b/packages/widgets/src/media-server/serverData.ts new file mode 100644 index 0000000000..952cfadb4b --- /dev/null +++ b/packages/widgets/src/media-server/serverData.ts @@ -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, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28484fa906..e4ebeed948 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -764,6 +764,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 @@ -904,6 +907,9 @@ importers: '@homarr/db': specifier: workspace:^ version: link:../db + '@homarr/definitions': + specifier: workspace:^ + version: link:../definitions '@homarr/log': specifier: workspace:^ version: link:../log @@ -1194,6 +1200,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.0(@mantine/hooks@7.11.0(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.0(@mantine/core@7.11.0(@mantine/hooks@7.11.0(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.0(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.0(react@18.3.1))(@tabler/icons-react@3.7.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) @@ -1380,10 +1389,18 @@ packages: resolution: {integrity: sha512-CvLSkwXGWnYlF9+J3iZUvwgAxKiYzK3BWuo+mLzD/MDGOZDj7Gq8+hqaOkMxmJwmlv0iu86uH5fdADd9Hxkymw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.23.4': + resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.24.6': resolution: {integrity: sha512-WdJjwMEkmBicq5T9fm/cHND3+UlFa2Yj8ALLgmoSQAJZysYbBjw+azChSGPN4DSPLXOcooGRvDwZWMcF/mLO2Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.22.20': + resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.24.6': resolution: {integrity: sha512-4yA7s865JHaqUdRbnaxarZREuPTHrjpDT+pXoAZ1yhyo6uFnIEpS8VMu16siFOHDpZNKYv5BObhsB//ycbICyw==} engines: {node: '>=6.9.0'} @@ -1400,6 +1417,11 @@ packages: resolution: {integrity: sha512-2YnuOp4HAk2BsBrJJvYCbItHx0zWscI1C3zgWkz+wDyD9I7GIVrfnLyrR4Y1VR+7p+chAEcrgRQYZAGIKMV7vQ==} engines: {node: '>=6.9.0'} + '@babel/parser@7.24.0': + resolution: {integrity: sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/parser@7.24.6': resolution: {integrity: sha512-eNZXdfU35nJC2h24RznROuOpO94h6x8sg9ju0tT9biNtLZ2vuP8SduLqqV+/8+cebSLV9SJEAN5Z3zQbJG/M+Q==} engines: {node: '>=6.0.0'} @@ -1433,6 +1455,10 @@ packages: resolution: {integrity: sha512-OsNjaJwT9Zn8ozxcfoBc+RaHdj3gFmCmYoQLUII1o6ZrUwku0BMg80FoOTPx+Gi6XhcQxAYE4xyjPTo4SxEQqw==} engines: {node: '>=6.9.0'} + '@babel/types@7.24.0': + resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==} + engines: {node: '>=6.9.0'} + '@babel/types@7.24.6': resolution: {integrity: sha512-WaMsgi6Q8zMgMth93GvWPXkhAIEobfsIkLTacoVZoK1J0CevIPGYY2Vo5YvJGqyHqXM6P4ppOYGsIRU8MM9pFQ==} engines: {node: '>=6.9.0'} @@ -1946,6 +1972,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} @@ -2900,6 +2931,11 @@ packages: resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} engines: {node: '>=0.4.0'} + acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} + engines: {node: '>=0.4.0'} + hasBin: true + acorn@8.12.0: resolution: {integrity: sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==} engines: {node: '>=0.4.0'} @@ -3070,6 +3106,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==} @@ -3974,6 +4013,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==} @@ -6545,7 +6593,7 @@ snapshots: '@babel/traverse': 7.24.6 '@babel/types': 7.24.6 convert-source-map: 2.0.0 - debug: 4.3.5 + debug: 4.3.4 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -6601,8 +6649,12 @@ snapshots: dependencies: '@babel/types': 7.24.6 + '@babel/helper-string-parser@7.23.4': {} + '@babel/helper-string-parser@7.24.6': {} + '@babel/helper-validator-identifier@7.22.20': {} + '@babel/helper-validator-identifier@7.24.6': {} '@babel/helper-validator-option@7.24.6': {} @@ -6619,6 +6671,10 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.0.0 + '@babel/parser@7.24.0': + dependencies: + '@babel/types': 7.24.6 + '@babel/parser@7.24.6': dependencies: '@babel/types': 7.24.6 @@ -6663,6 +6719,12 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/types@7.24.0': + dependencies: + '@babel/helper-string-parser': 7.23.4 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + '@babel/types@7.24.6': dependencies: '@babel/helper-string-parser': 7.24.6 @@ -6988,6 +7050,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 @@ -8050,6 +8116,8 @@ snapshots: acorn-walk@8.3.2: {} + acorn@8.11.3: {} + acorn@8.12.0: {} aes-decrypter@4.0.1: @@ -8278,6 +8346,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 @@ -8777,7 +8853,7 @@ snapshots: docker-modem@5.0.3: dependencies: - debug: 4.3.5 + debug: 4.3.4 readable-stream: 3.6.2 split-ca: 1.0.1 ssh2: 1.15.0 @@ -9395,6 +9471,8 @@ snapshots: fn.name@1.1.0: {} + follow-redirects@1.15.6: {} + for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -9640,7 +9718,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.0 - debug: 4.3.5 + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -9654,7 +9732,7 @@ snapshots: https-proxy-agent@7.0.4: dependencies: agent-base: 7.1.0 - debug: 4.3.5 + debug: 4.3.4 transitivePeerDependencies: - supports-color @@ -9927,7 +10005,7 @@ snapshots: istanbul-lib-source-maps@5.0.4: dependencies: '@jridgewell/trace-mapping': 0.3.25 - debug: 4.3.5 + debug: 4.3.4 istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -10169,8 +10247,8 @@ snapshots: magicast@0.3.3: dependencies: - '@babel/parser': 7.24.6 - '@babel/types': 7.24.6 + '@babel/parser': 7.24.0 + '@babel/types': 7.24.0 source-map-js: 1.2.0 make-dir@3.1.0: @@ -10270,7 +10348,7 @@ snapshots: mlly@1.5.0: dependencies: - acorn: 8.12.0 + acorn: 8.11.3 pathe: 1.1.2 pkg-types: 1.0.3 ufo: 1.4.0 @@ -11617,7 +11695,7 @@ snapshots: '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 '@types/node': 20.14.9 - acorn: 8.12.0 + acorn: 8.11.3 acorn-walk: 8.3.2 arg: 4.1.3 create-require: 1.1.1