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:
Manuel
2024-07-03 20:06:57 +02:00
committed by GitHub
parent 1cf119c768
commit bb8640b162
25 changed files with 435 additions and 17 deletions

View File

@@ -11,10 +11,11 @@
"cSpell.words": [
"cqmin",
"homarr",
"Sonarr",
"jellyfin",
"superjson",
"trpc",
"Umami"
"Umami",
"Sonarr"
],
"i18n-ally.dirStructure": "auto",
"i18n-ally.enabledFrameworks": ["next-international"],

View File

@@ -206,8 +206,8 @@ const ItemMenu = ({
return (
<Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target>
<ActionIcon variant="transparent" pos="absolute" top={offset} right={offset} style={{ zIndex: 1 }}>
<IconDotsVertical />
<ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
<IconDotsVertical size={"1rem"} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>

View File

@@ -1,5 +1,5 @@
import type { CalendarEvent } from "@homarr/integrations/types";
import { createItemWithIntegrationChannel } from "@homarr/redis";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { createManyIntegrationOfOneItemMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -10,10 +10,8 @@ export const calendarRouter = createTRPCRouter({
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.flatMap(async (integration) => {
for (const item of integration.items) {
const cache = createItemWithIntegrationChannel<CalendarEvent[]>(item.itemId, integration.id);
return await cache.getAsync();
}
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
return await cache.getAsync();
}),
);
}),

View File

@@ -2,6 +2,7 @@ import { createTRPCRouter } from "../../trpc";
import { appRouter } from "./app";
import { calendarRouter } from "./calendar";
import { dnsHoleRouter } from "./dns-hole";
import { mediaServerRouter } from "./media-server";
import { notebookRouter } from "./notebook";
import { smartHomeRouter } from "./smart-home";
import { weatherRouter } from "./weather";
@@ -12,5 +13,6 @@ export const widgetRouter = createTRPCRouter({
app: appRouter,
dnsHole: dnsHoleRouter,
smartHome: smartHomeRouter,
mediaServer: mediaServerRouter,
calendar: calendarRouter,
});

View 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,
});
});
}
});
}),
});

View File

@@ -2,6 +2,7 @@ import { analyticsJob } from "./jobs/analytics";
import { iconsUpdaterJob } from "./jobs/icons-updater";
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
import { mediaServerJob } from "./jobs/integrations/media-server";
import { pingJob } from "./jobs/ping";
import { createCronJobGroup } from "./lib";
@@ -10,6 +11,7 @@ export const jobGroup = createCronJobGroup({
iconsUpdater: iconsUpdaterJob,
ping: pingJob,
smartHomeEntityState: smartHomeEntityStateJob,
mediaServer: mediaServerJob,
mediaOrganizer: mediaOrganizerJob,
});

View File

@@ -7,7 +7,7 @@ import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";
import { SonarrIntegration } from "@homarr/integrations";
import type { CalendarEvent } from "@homarr/integrations/types";
import { createItemWithIntegrationChannel } from "@homarr/redis";
import { createItemAndIntegrationChannel } from "@homarr/redis";
// This import is done that way to avoid circular dependencies.
import type { WidgetComponentProps } from "../../../../widgets";
@@ -50,7 +50,7 @@ export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).w
});
const events = await sonarr.getCalendarEventsAsync(start, end);
const cache = createItemWithIntegrationChannel<CalendarEvent[]>(itemForIntegration.id, integration.integrationId);
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.integrationId);
await cache.setAsync(events);
}
}

View 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);
}
}
});

View File

@@ -8,6 +8,7 @@ export const widgetKinds = [
"dnsHoleSummary",
"smartHome-entityState",
"smartHome-executeAutomation",
"mediaServer",
"calendar",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

@@ -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": {

View File

@@ -1,6 +1,7 @@
import type { IntegrationKind } from "@homarr/definitions";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { PiHoleIntegration } from "../pi-hole/pi-hole-integration";
import type { IntegrationInput } from "./integration";
@@ -11,6 +12,8 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int
return new PiHoleIntegration(integration);
case "homeAssistant":
return new HomeAssistantIntegration(integration);
case "jellyfin":
return new JellyfinIntegration(integration);
case "sonarr":
return new SonarrIntegration(integration);
default:

View File

@@ -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";

View 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;
}

View 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);
}
}

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
import { createListChannel, createQueueChannel, createSubPubChannel } from "./lib/channel";
export { createCacheChannel, createItemWithIntegrationChannel } from "./lib/channel";
export { createCacheChannel, createItemAndIntegrationChannel } from "./lib/channel";
export const exampleChannel = createSubPubChannel<{ message: string }>("example");
export const pingChannel = createSubPubChannel<{ url: string; statusCode: number } | { url: string; error: string }>(

View File

@@ -1,6 +1,7 @@
import superjson from "superjson";
import { createId } from "@homarr/db";
import type { WidgetKind } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { createRedisConnection } from "./connection";
@@ -168,8 +169,35 @@ export const createCacheChannel = <TData>(name: string, cacheDurationMs: number
};
};
export const createItemWithIntegrationChannel = <T>(itemId: string, integrationId: string) =>
createCacheChannel<T>(`item:${itemId}:integration:${integrationId}`);
export const createItemAndIntegrationChannel = <TData>(kind: WidgetKind, integrationId: string) => {
const channelName = `item:${kind}:integration:${integrationId}`;
return {
subscribeAsync: async (callback: (data: TData) => void) => {
await subscriber.subscribe(channelName);
subscriber.on("message", (channel, message) => {
if (channel !== channelName) {
logger.warn(`received message on ${channel} channel but was looking for ${channelName}`);
return;
}
callback(superjson.parse(message));
logger.debug(`sent message on ${channelName}`);
});
},
publishAndUpdateLastStateAsync: async (data: TData) => {
await publisher.publish(channelName, superjson.stringify(data));
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
},
setAsync: async (data: TData) => {
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
},
getAsync: async () => {
const data = await getSetClient.get(channelName);
if (!data) return null;
return superjson.parse<{ data: TData; timestamp: Date }>(data);
},
};
};
const queueClient = createRedisConnection();

View File

@@ -986,6 +986,7 @@ export default {
},
noIntegration: "No integration selected",
},
option: {},
},
video: {
name: "Video Stream",
@@ -1010,6 +1011,11 @@ export default {
forYoutubeUseIframe: "For YouTube videos use the iframe option",
},
},
mediaServer: {
name: "Current media server streams",
description: "Show the current streams on your media servers",
option: {},
},
},
widgetPreview: {
toggle: {
@@ -1485,6 +1491,9 @@ export default {
ping: {
label: "Pings",
},
mediaServer: {
label: "Media Server",
},
mediaOrganizer: {
label: "Media Organizers",
},

View File

@@ -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"
},

View File

@@ -23,7 +23,7 @@ export default async function getServerDataAsync({ integrationIds, itemId }: Wid
(
item,
): item is Exclude<Exclude<RouterOutputs["widget"]["calendar"]["findAllEvents"][number], null>, undefined> =>
item !== null && item !== undefined,
item !== null,
)
.flatMap((item) => item.data),
};

View File

@@ -12,6 +12,7 @@ import type { WidgetComponentProps } from "./definition";
import * as dnsHoleSummary from "./dns-hole/summary";
import * as iframe from "./iframe";
import type { WidgetImportRecord } from "./import";
import * as mediaServer from "./media-server";
import * as notebook from "./notebook";
import * as smartHomeEntityState from "./smart-home/entity-state";
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
@@ -34,6 +35,7 @@ export const widgetImports = {
dnsHoleSummary,
"smartHome-entityState": smartHomeEntityState,
"smartHome-executeAutomation": smartHomeExecuteAutomation,
mediaServer,
calendar,
} satisfies WidgetImportRecord;

View 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>
);
}

View 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"));

View 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
View File

@@ -890,6 +890,9 @@ importers:
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
'@jellyfin/sdk':
specifier: ^0.10.0
version: 0.10.0(axios@1.7.2)
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0
@@ -1030,6 +1033,9 @@ importers:
'@homarr/db':
specifier: workspace:^
version: link:../db
'@homarr/definitions':
specifier: workspace:^
version: link:../definitions
'@homarr/log':
specifier: workspace:^
version: link:../log
@@ -1320,6 +1326,9 @@ importers:
dayjs:
specifier: ^1.11.11
version: 1.11.11
mantine-react-table:
specifier: 2.0.0-beta.5
version: 2.0.0-beta.5(@mantine/core@7.11.1(@mantine/hooks@7.11.1(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/dates@7.11.1(@mantine/core@7.11.1(@mantine/hooks@7.11.1(react@18.3.1))(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.11.1(react@18.3.1))(dayjs@1.11.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.11.1(react@18.3.1))(@tabler/icons-react@3.8.0(react@18.3.1))(clsx@2.1.1)(dayjs@1.11.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next:
specifier: ^14.2.4
version: 14.2.4(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.6)
@@ -2089,6 +2098,11 @@ packages:
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
engines: {node: '>=8'}
'@jellyfin/sdk@0.10.0':
resolution: {integrity: sha512-fUUwiPOGQEFYxnS9olYkv7GXIX5N9JYdRBR8bapN86OhbHWzL1JHgWf/sAUcNTQGlCWMKTJqve4KFOQB1FlMAQ==}
peerDependencies:
axios: ^1.3.4
'@jest/schemas@29.6.3':
resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -3218,6 +3232,9 @@ packages:
resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==}
engines: {node: '>=4'}
axios@1.7.2:
resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==}
axobject-query@3.1.1:
resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==}
@@ -4122,6 +4139,15 @@ packages:
fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
follow-redirects@1.15.6:
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
for-each@0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@@ -7150,6 +7176,10 @@ snapshots:
'@istanbuljs/schema@0.1.3': {}
'@jellyfin/sdk@0.10.0(axios@1.7.2)':
dependencies:
axios: 1.7.2
'@jest/schemas@29.6.3':
dependencies:
'@sinclair/typebox': 0.27.8
@@ -8442,6 +8472,14 @@ snapshots:
axe-core@4.9.1: {}
axios@1.7.2:
dependencies:
follow-redirects: 1.15.6
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axobject-query@3.1.1:
dependencies:
deep-equal: 2.2.3
@@ -9559,6 +9597,8 @@ snapshots:
fn.name@1.1.0: {}
follow-redirects@1.15.6: {}
for-each@0.3.3:
dependencies:
is-callable: 1.2.7