From 06d9c07d2226343f5de244dd3e09f9664a0ddc6b Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Fri, 31 Jul 2026 04:30:44 -0700 Subject: [PATCH] feat: expose Navidrome as a media-server (now-playing) integration (#6070) --- .../docs/integrations/navidrome/index.mdx | 6 +- apps/docs/docs/widgets/media-server/index.mdx | 5 +- apps/docs/docs/widgets/media-server/index.ts | 2 +- .../api/src/router/widgets/media-server.ts | 2 +- packages/definitions/src/integration.ts | 2 +- .../src/navidrome/navidrome-integration.ts | 27 +++- .../test/navidrome-integration.spec.ts | 129 +++++++++++++++++- .../src/audio-stats/audio-stats-content.tsx | 61 +-------- packages/widgets/src/audio-stats/index.ts | 12 -- packages/widgets/src/audio-stats/shared.ts | 3 - 10 files changed, 166 insertions(+), 83 deletions(-) diff --git a/apps/docs/docs/integrations/navidrome/index.mdx b/apps/docs/docs/integrations/navidrome/index.mdx index 664e6fc58..6a9f4059e 100644 --- a/apps/docs/docs/integrations/navidrome/index.mdx +++ b/apps/docs/docs/integrations/navidrome/index.mdx @@ -10,19 +10,21 @@ import { AddingIntegration } from '@site/src/components/integrations/adding'; import { IntegrationSecrets } from '@site/src/components/integrations/secrets'; import { navidromeIntegration } from '.'; import { audioStatsWidget } from '@site/docs/widgets/audio-stats'; +import { mediaServerWidget } from '@site/docs/widgets/media-server'; -Navidrome provides music library statistics including artist, album, and song counts, as well as now playing information via the Subsonic API. +Navidrome provides music library statistics including artist, album, and song counts, as well as now playing audio streams via the Subsonic API. ### Widgets & Capabilities diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx index accd83f8e..8fb0c857c 100644 --- a/apps/docs/docs/widgets/media-server/index.mdx +++ b/apps/docs/docs/widgets/media-server/index.mdx @@ -14,6 +14,7 @@ import TabItem from '@theme/TabItem'; import { mediaServerWidget } from '.'; import { embyIntegration } from '@site/docs/integrations/emby'; import { jellyfinIntegration } from '@site/docs/integrations/jellyfin'; +import { navidromeIntegration } from '@site/docs/integrations/navidrome'; import { plexIntegration } from '@site/docs/integrations/plex'; @@ -39,6 +40,9 @@ import mediaTable from './img/table.png'; integration: embyIntegration, }, { integration: jellyfinIntegration, +}, { + integration: navidromeIntegration, + note: "Will always show only currently playing audio streams, not all active sessions." }, { integration: plexIntegration, note: "Will always show only currently playing streams, not all active sessions." @@ -50,4 +54,3 @@ import mediaTable from './img/table.png'; ### Configuration - diff --git a/apps/docs/docs/widgets/media-server/index.ts b/apps/docs/docs/widgets/media-server/index.ts index b9f5ec76f..419f860a1 100644 --- a/apps/docs/docs/widgets/media-server/index.ts +++ b/apps/docs/docs/widgets/media-server/index.ts @@ -10,7 +10,7 @@ export const mediaServerWidget: WidgetDefinition = { items: [ { name: "Show only currently playing", - description: "Disabling this will not work for plex.", + description: "Plex and Navidrome always show only currently playing streams.", values: { type: "boolean" }, defaultValue: "yes", }, diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 3e3e64a4f..591d58d2b 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -17,7 +17,7 @@ export const mediaServerRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Get currently active streams from Plex/Jellyfin/Emby media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean โ€” true to filter to actively playing streams only)", + "Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean โ€” true to filter to actively playing streams only)", }, }) .concat(createMediaServerIntegrationMiddleware("query")) diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 2116cf4da..88c6a7088 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -418,7 +418,7 @@ export const integrationDefs = { name: "Navidrome", secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg", - category: ["mediaLibrary"], + category: ["mediaLibrary", "mediaService"], documentationUrl: null, defaultPort: 4533, }, diff --git a/packages/integrations/src/navidrome/navidrome-integration.ts b/packages/integrations/src/navidrome/navidrome-integration.ts index 4d7609ea0..733fa92a0 100644 --- a/packages/integrations/src/navidrome/navidrome-integration.ts +++ b/packages/integrations/src/navidrome/navidrome-integration.ts @@ -6,6 +6,8 @@ import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; import type { TestingResult } from "../base/test-connection/test-connection-service"; +import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration"; +import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types"; import type { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types"; import { subsonicResponseSchema } from "./navidrome-types"; @@ -22,7 +24,7 @@ const subsonicAuthErrorCodes = new Set([40, 41]); const asArray = (value: TValue | TValue[] | undefined): TValue[] => [value].flat().filter((item): item is TValue => item !== undefined); -export class NavidromeIntegration extends Integration { +export class NavidromeIntegration extends Integration implements IMediaServerIntegration { protected async testingAsync(input: IntegrationTestingInput): Promise { const url = this.url("/rest/ping.view", this.getAuthParams()); const response = await input.fetchAsync(url); @@ -67,6 +69,29 @@ export class NavidromeIntegration extends Integration { }; } + public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise { + const nowPlaying = await this.getNowPlayingAsync(); + + return nowPlaying.map((entry): StreamSession => ({ + sessionId: `${entry.username}-${entry.playerName}`, + sessionName: entry.playerName, + user: { + userId: entry.username, + username: entry.username, + profilePictureUrl: null, + }, + currentlyPlaying: { + type: "audio", + name: entry.title, + seasonName: entry.artist || undefined, + episodeName: null, + albumName: entry.album, + episodeCount: null, + metadata: null, + }, + })); + } + private async getArtistCountAsync(): Promise { const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true }); return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0); diff --git a/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts b/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts index 921169b1a..b3fa10141 100644 --- a/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts +++ b/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts @@ -34,9 +34,9 @@ const subsonicOk = >(data: T) => "subsonic-response": { status: "ok", version: "1.16.1", ...data }, }); -const subsonicFailed = (message: string) => +const subsonicFailed = (message: string, code = 70) => JSON.stringify({ - "subsonic-response": { status: "failed", error: { code: 70, message } }, + "subsonic-response": { status: "failed", error: { code, message } }, }); beforeEach(() => { @@ -199,3 +199,128 @@ describe("NavidromeIntegration.getDashboardDataAsync", () => { }); }); }); + +describe("NavidromeIntegration.getCurrentSessionsAsync", () => { + test("maps now playing entries to stream sessions", async () => { + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + + if (urlStr.includes("getNowPlaying")) { + return Promise.resolve( + new Response( + subsonicOk({ + nowPlaying: { + entry: [ + { + title: "Song", + artist: "Band", + album: "LP", + username: "user1", + playerName: "Chrome", + }, + { + title: "Second Song", + artist: "Second Artist", + album: "Second Album", + username: "user2", + playerName: "Mobile", + }, + ], + }, + }), + { status: 200 }, + ), + ) as unknown as ReturnType; + } + + return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + }); + + const integration = createIntegration(); + const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: true }); + + expect(result).toEqual([ + { + sessionId: "user1-Chrome", + sessionName: "Chrome", + user: { + userId: "user1", + username: "user1", + profilePictureUrl: null, + }, + currentlyPlaying: { + type: "audio", + name: "Song", + seasonName: "Band", + episodeName: null, + albumName: "LP", + episodeCount: null, + metadata: null, + }, + }, + { + sessionId: "user2-Mobile", + sessionName: "Mobile", + user: { + userId: "user2", + username: "user2", + profilePictureUrl: null, + }, + currentlyPlaying: { + type: "audio", + name: "Second Song", + seasonName: "Second Artist", + episodeName: null, + albumName: "Second Album", + episodeCount: null, + metadata: null, + }, + }, + ]); + }); + + test("returns an empty array when there are no now playing entries", async () => { + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + + if (urlStr.includes("getNowPlaying")) { + return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + } + + return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + }); + + const integration = createIntegration(); + const result = await integration.getCurrentSessionsAsync({ showOnlyPlaying: false }); + + expect(result).toEqual([]); + }); + + test("throws on subsonic auth failure", async () => { + mockFetch.mockImplementation((url) => { + const urlStr = toUrlString(url); + + if (urlStr.includes("getNowPlaying")) { + return Promise.resolve( + new Response(subsonicFailed("Wrong username or password", 40), { status: 200 }), + ) as unknown as ReturnType; + } + + return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType< + typeof fetchWithTrustedCertificatesAsync + >; + }); + + const integration = createIntegration(); + + await expect(integration.getCurrentSessionsAsync({ showOnlyPlaying: true })).rejects.toThrow( + "Wrong username or password", + ); + }); +}); diff --git a/packages/widgets/src/audio-stats/audio-stats-content.tsx b/packages/widgets/src/audio-stats/audio-stats-content.tsx index 3b17223b8..b6824a1fe 100644 --- a/packages/widgets/src/audio-stats/audio-stats-content.tsx +++ b/packages/widgets/src/audio-stats/audio-stats-content.tsx @@ -1,8 +1,7 @@ "use client"; import type { CSSProperties } from "react"; -import { Badge, Text } from "@mantine/core"; -import { IconHeadphones } from "@tabler/icons-react"; +import { Text } from "@mantine/core"; import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; @@ -40,29 +39,6 @@ export function AudioStatsContent({ backend, stats, options, width }: AudioStats const gridCols = getGridCols(width, visibleStats.length, options.compactMode ?? false); const iconSize = getIconSize(width, options.compactMode ?? false); - const navidromeStatsByBackend: Record = { - navidrome: stats as NavidromeDashboardData, - audiobookshelf: null, - }; - const navidromeStats = navidromeStatsByBackend[backend]; - const showNowPlayingSection = - navidromeStats !== null && Boolean(options.showNowPlaying) && navidromeStats.nowPlaying.length > 0; - - const nowPlayingLimitByListVisibility = { - true: options.maxNowPlayingItems ?? 3, - false: 0, - } as const; - - const nowPlayingTracks = - navidromeStats?.nowPlaying.slice( - 0, - nowPlayingLimitByListVisibility[ - String(options.showNowPlayingList) as keyof typeof nowPlayingLimitByListVisibility - ], - ) ?? []; - - const hasContent = visibleStats.length > 0 || showNowPlayingSection; - return (
{visibleStats.length > 0 && ( @@ -80,40 +56,7 @@ export function AudioStatsContent({ backend, stats, options, width }: AudioStats
)} - {showNowPlayingSection && navidromeStats && ( -
-
- - {t("nowPlaying" as never)} - {!options.showNowPlayingList && ( - - {(t as unknown as (key: string, params?: { count: number }) => string)("nowPlayingCount", { - count: navidromeStats.nowPlaying.length, - })} - - )} -
- {options.showNowPlayingList && ( -
- {nowPlayingTracks.map((track, index) => ( -
-
- {track.title} - - {[track.artist, track.album].filter(Boolean).join(" ยท ")} - -
- - {track.username || track.playerName} - -
- ))} -
- )} -
- )} - - {!hasContent && ( + {visibleStats.length === 0 && (
โ€” diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts index 25af0bdaf..ffcd2dd93 100644 --- a/packages/widgets/src/audio-stats/index.ts +++ b/packages/widgets/src/audio-stats/index.ts @@ -1,5 +1,4 @@ import { IconHeadphones, IconServerOff } from "@tabler/icons-react"; -import { z } from "zod/v4"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -23,14 +22,6 @@ export const { definition, componentLoader } = createWidgetDefinition("audioStat showArtists: factory.switch({ defaultValue: true, withDescription: true }), showAlbums: factory.switch({ defaultValue: true, withDescription: true }), showSongs: factory.switch({ defaultValue: true, withDescription: true }), - showNowPlaying: factory.switch({ defaultValue: true, withDescription: true }), - showNowPlayingList: factory.switch({ defaultValue: true, withDescription: true }), - maxNowPlayingItems: factory.number({ - defaultValue: 3, - validate: z.number().min(1).max(5), - step: 1, - withDescription: true, - }), showLibraryCount: factory.switch({ defaultValue: true, withDescription: true }), showAudiobooks: factory.switch({ defaultValue: true, withDescription: true }), showPodcasts: factory.switch({ defaultValue: true, withDescription: true }), @@ -42,9 +33,6 @@ export const { definition, componentLoader } = createWidgetDefinition("audioStat showArtists: hideUnlessNavidrome, showAlbums: hideUnlessNavidrome, showSongs: hideUnlessNavidrome, - showNowPlaying: hideUnlessNavidrome, - showNowPlayingList: hideUnlessNavidrome, - maxNowPlayingItems: hideUnlessNavidrome, showLibraryCount: hideUnlessAudiobookshelf, showAudiobooks: hideUnlessAudiobookshelf, showPodcasts: hideUnlessAudiobookshelf, diff --git a/packages/widgets/src/audio-stats/shared.ts b/packages/widgets/src/audio-stats/shared.ts index 39fc9b0b6..d4b79c72c 100644 --- a/packages/widgets/src/audio-stats/shared.ts +++ b/packages/widgets/src/audio-stats/shared.ts @@ -19,9 +19,6 @@ export interface NavidromeDisplayOptions { showArtists: boolean; showAlbums: boolean; showSongs: boolean; - showNowPlaying: boolean; - showNowPlayingList: boolean; - maxNowPlayingItems: number; compactMode?: boolean; }