feat: expose Navidrome as a media-server (now-playing) integration (#6070)
This commit is contained in:
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user