feat: expose Navidrome as a media-server (now-playing) integration (#6070)

This commit is contained in:
Matt Van Horn authored and GitHub committed 2026-07-31 13:30:44 +02:00
1 parent 49d0f1a141
commit 06d9c07d22
10 files changed
+166 -83

No files matched your search

@@ -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 = <TValue>(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<TestingResult> {
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<StreamSession[]> {
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<number> {
const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true });
return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0);
@@ -34,9 +34,9 @@ const subsonicOk = <T extends Record<string, unknown>>(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<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: 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<typeof fetchWithTrustedCertificatesAsync>;
}
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",
);
});
});