mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Scrobbling on play
This commit is contained in:
@@ -42,7 +42,9 @@ export class InMemoryMusicService implements MusicService {
|
||||
this.users[username] == password
|
||||
) {
|
||||
return Promise.resolve({
|
||||
authToken: Buffer.from(JSON.stringify({ username, password })).toString('base64'),
|
||||
authToken: Buffer.from(JSON.stringify({ username, password })).toString(
|
||||
"base64"
|
||||
),
|
||||
userId: username,
|
||||
nickname: username,
|
||||
});
|
||||
@@ -52,7 +54,9 @@ export class InMemoryMusicService implements MusicService {
|
||||
}
|
||||
|
||||
login(token: string): Promise<MusicLibrary> {
|
||||
const credentials = JSON.parse(Buffer.from(token, "base64").toString("ascii")) as Credentials;
|
||||
const credentials = JSON.parse(
|
||||
Buffer.from(token, "base64").toString("ascii")
|
||||
) as Credentials;
|
||||
if (this.users[credentials.username] != credentials.password)
|
||||
return Promise.reject("Invalid auth token");
|
||||
|
||||
@@ -103,18 +107,24 @@ export class InMemoryMusicService implements MusicService {
|
||||
A.sort(ordString)
|
||||
)
|
||||
),
|
||||
tracks: (albumId: string) => Promise.resolve(this.tracks.filter(it => it.album.id === albumId)),
|
||||
track: (trackId: string) => pipe(
|
||||
this.tracks.find(it => it.id === trackId),
|
||||
O.fromNullable,
|
||||
O.map(it => Promise.resolve(it)),
|
||||
O.getOrElse(() => Promise.reject(`Failed to find track with id ${trackId}`))
|
||||
),
|
||||
stream: (_: {
|
||||
trackId: string;
|
||||
range: string | undefined;
|
||||
}) => Promise.reject("unsupported operation"),
|
||||
coverArt: (id: string, _: "album" | "artist", size?: number) => Promise.reject(`Cannot retrieve coverArt for ${id}, size ${size}`)
|
||||
tracks: (albumId: string) =>
|
||||
Promise.resolve(this.tracks.filter((it) => it.album.id === albumId)),
|
||||
track: (trackId: string) =>
|
||||
pipe(
|
||||
this.tracks.find((it) => it.id === trackId),
|
||||
O.fromNullable,
|
||||
O.map((it) => Promise.resolve(it)),
|
||||
O.getOrElse(() =>
|
||||
Promise.reject(`Failed to find track with id ${trackId}`)
|
||||
)
|
||||
),
|
||||
stream: (_: { trackId: string; range: string | undefined }) =>
|
||||
Promise.reject("unsupported operation"),
|
||||
coverArt: (id: string, _: "album" | "artist", size?: number) =>
|
||||
Promise.reject(`Cannot retrieve coverArt for ${id}, size ${size}`),
|
||||
scrobble: async (_: string) => {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ const ok = (data: string) => ({
|
||||
});
|
||||
|
||||
const artistInfoXml = (
|
||||
artist: Artist,
|
||||
artist: Artist
|
||||
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
|
||||
<artistInfo>
|
||||
<biography></biography>
|
||||
@@ -77,7 +77,10 @@ const artistInfoXml = (
|
||||
<smallImageUrl>${artist.image.small || ""}</smallImageUrl>
|
||||
<mediumImageUrl>${artist.image.medium || ""}</mediumImageUrl>
|
||||
<largeImageUrl>${artist.image.large || ""}</largeImageUrl>
|
||||
${artist.similarArtists.map(it => `<similarArtist id="${it.id}" name="${it.name}" albumCount="3"></similarArtist>`)}
|
||||
${artist.similarArtists.map(
|
||||
(it) =>
|
||||
`<similarArtist id="${it.id}" name="${it.name}" albumCount="3"></similarArtist>`
|
||||
)}
|
||||
</artistInfo>
|
||||
</subsonic-response>`;
|
||||
|
||||
@@ -173,6 +176,8 @@ const getSongXml = (
|
||||
)}
|
||||
</subsonic-response>`;
|
||||
|
||||
const EMPTY = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
|
||||
|
||||
const PING_OK = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
|
||||
|
||||
describe("Navidrome", () => {
|
||||
@@ -185,12 +190,14 @@ describe("Navidrome", () => {
|
||||
|
||||
const mockedRandomString = (randomString as unknown) as jest.Mock;
|
||||
const mockGET = jest.fn();
|
||||
const mockPOST = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
|
||||
axios.get = mockGET;
|
||||
axios.post = mockPOST;
|
||||
|
||||
mockedRandomString.mockReturnValue(salt);
|
||||
});
|
||||
@@ -310,7 +317,10 @@ describe("Navidrome", () => {
|
||||
medium: `http://localhost:80/${DODGY_IMAGE_NAME}`,
|
||||
large: `http://localhost:80/${DODGY_IMAGE_NAME}`,
|
||||
},
|
||||
similarArtists: [{ id: "similar1.id", name: "similar1" }, { id: "similar2.id", name: "similar2" }],
|
||||
similarArtists: [
|
||||
{ id: "similar1.id", name: "similar1" },
|
||||
{ id: "similar2.id", name: "similar2" },
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -340,7 +350,7 @@ describe("Navidrome", () => {
|
||||
large: undefined,
|
||||
},
|
||||
albums: artist.albums,
|
||||
similarArtists: artist.similarArtists
|
||||
similarArtists: artist.similarArtists,
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
|
||||
@@ -403,7 +413,7 @@ describe("Navidrome", () => {
|
||||
large: undefined,
|
||||
},
|
||||
albums: artist.albums,
|
||||
similarArtists: artist.similarArtists
|
||||
similarArtists: artist.similarArtists,
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
|
||||
@@ -422,7 +432,7 @@ describe("Navidrome", () => {
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("and has no similar artists", () => {
|
||||
const album1: Album = anAlbum();
|
||||
@@ -466,7 +476,7 @@ describe("Navidrome", () => {
|
||||
large: undefined,
|
||||
},
|
||||
albums: artist.albums,
|
||||
similarArtists: artist.similarArtists
|
||||
similarArtists: artist.similarArtists,
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
|
||||
@@ -485,7 +495,7 @@ describe("Navidrome", () => {
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("and has dodgy looking artist image uris", () => {
|
||||
const album1: Album = anAlbum();
|
||||
@@ -529,7 +539,7 @@ describe("Navidrome", () => {
|
||||
large: undefined,
|
||||
},
|
||||
albums: artist.albums,
|
||||
similarArtists: []
|
||||
similarArtists: [],
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
|
||||
@@ -557,7 +567,7 @@ describe("Navidrome", () => {
|
||||
|
||||
const artist: Artist = anArtist({
|
||||
albums: [album1, album2],
|
||||
similarArtists: []
|
||||
similarArtists: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -583,7 +593,7 @@ describe("Navidrome", () => {
|
||||
name: artist.name,
|
||||
image: artist.image,
|
||||
albums: artist.albums,
|
||||
similarArtists: []
|
||||
similarArtists: [],
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
|
||||
@@ -609,7 +619,7 @@ describe("Navidrome", () => {
|
||||
|
||||
const artist: Artist = anArtist({
|
||||
albums: [album],
|
||||
similarArtists: []
|
||||
similarArtists: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -635,7 +645,7 @@ describe("Navidrome", () => {
|
||||
name: artist.name,
|
||||
image: artist.image,
|
||||
albums: artist.albums,
|
||||
similarArtists: []
|
||||
similarArtists: [],
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
|
||||
@@ -659,7 +669,7 @@ describe("Navidrome", () => {
|
||||
describe("and has no albums", () => {
|
||||
const artist: Artist = anArtist({
|
||||
albums: [],
|
||||
similarArtists: []
|
||||
similarArtists: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -685,7 +695,7 @@ describe("Navidrome", () => {
|
||||
name: artist.name,
|
||||
image: artist.image,
|
||||
albums: [],
|
||||
similarArtists: []
|
||||
similarArtists: [],
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
|
||||
@@ -1662,7 +1672,7 @@ describe("Navidrome", () => {
|
||||
const artist = anArtist({
|
||||
id: artistId,
|
||||
albums: [album1, album2],
|
||||
image: images
|
||||
image: images,
|
||||
});
|
||||
|
||||
mockGET
|
||||
@@ -1737,7 +1747,11 @@ describe("Navidrome", () => {
|
||||
data: Buffer.from("the image", "ascii"),
|
||||
};
|
||||
|
||||
const artist = anArtist({ id: artistId, albums: [], image: images });
|
||||
const artist = anArtist({
|
||||
id: artistId,
|
||||
albums: [],
|
||||
image: images,
|
||||
});
|
||||
|
||||
mockGET
|
||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||
@@ -1879,7 +1893,7 @@ describe("Navidrome", () => {
|
||||
const artist = anArtist({
|
||||
id: artistId,
|
||||
albums: [album1, album2],
|
||||
image: images
|
||||
image: images,
|
||||
});
|
||||
|
||||
mockGET
|
||||
@@ -1955,7 +1969,11 @@ describe("Navidrome", () => {
|
||||
data: Buffer.from("the image", "ascii"),
|
||||
};
|
||||
|
||||
const artist = anArtist({ id: artistId, albums: [], image: images });
|
||||
const artist = anArtist({
|
||||
id: artistId,
|
||||
albums: [],
|
||||
image: images,
|
||||
});
|
||||
|
||||
mockGET
|
||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||
@@ -2022,7 +2040,7 @@ describe("Navidrome", () => {
|
||||
const artist = anArtist({
|
||||
id: artistId,
|
||||
albums: [album1, album2],
|
||||
image: images
|
||||
image: images,
|
||||
});
|
||||
|
||||
mockGET
|
||||
@@ -2098,7 +2116,11 @@ describe("Navidrome", () => {
|
||||
data: Buffer.from("the image", "ascii"),
|
||||
};
|
||||
|
||||
const artist = anArtist({ id: artistId, albums: [], image: images });
|
||||
const artist = anArtist({
|
||||
id: artistId,
|
||||
albums: [],
|
||||
image: images,
|
||||
});
|
||||
|
||||
mockGET
|
||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||
@@ -2142,4 +2164,63 @@ describe("Navidrome", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrobble", () => {
|
||||
describe("when scrobbling succeeds", () => {
|
||||
it("should return true", async () => {
|
||||
const id = uuid();
|
||||
|
||||
mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)));
|
||||
|
||||
mockPOST.mockImplementationOnce(() => Promise.resolve(ok(EMPTY)));
|
||||
|
||||
const result = await navidrome
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.scrobble(id));
|
||||
|
||||
expect(result).toEqual(true);
|
||||
|
||||
expect(mockPOST).toHaveBeenCalledWith(`${url}/rest/scrobble`, {
|
||||
params: {
|
||||
id,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when scrobbling fails", () => {
|
||||
it("should return false", async () => {
|
||||
const id = uuid();
|
||||
|
||||
mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)));
|
||||
|
||||
mockPOST.mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
status: 500,
|
||||
data: {},
|
||||
})
|
||||
);
|
||||
|
||||
const result = await navidrome
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.scrobble(id));
|
||||
|
||||
expect(result).toEqual(false);
|
||||
|
||||
expect(mockPOST).toHaveBeenCalledWith(`${url}/rest/scrobble`, {
|
||||
params: {
|
||||
id,
|
||||
...authParams,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,6 +198,7 @@ describe("server", () => {
|
||||
};
|
||||
const musicLibrary = {
|
||||
stream: jest.fn(),
|
||||
scrobble: jest.fn()
|
||||
};
|
||||
let now = dayjs();
|
||||
const accessTokens = new ExpiringAccessTokens({ now: () => now });
|
||||
@@ -239,6 +240,54 @@ describe("server", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrobbling", () => {
|
||||
describe("when scrobbling succeeds", () => {
|
||||
it("should scrobble the track", async () => {
|
||||
const stream = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mp3",
|
||||
},
|
||||
data: Buffer.from("some track", "ascii"),
|
||||
};
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
expect(res.status).toEqual(stream.status);
|
||||
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when scrobbling succeeds", () => {
|
||||
it("should still return the track", async () => {
|
||||
const stream = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mp3",
|
||||
},
|
||||
data: Buffer.from("some track", "ascii"),
|
||||
};
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.scrobble.mockResolvedValue(false);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
expect(res.status).toEqual(stream.status);
|
||||
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when sonos does not ask for a range", () => {
|
||||
describe("when the music service does not return a content-range, content-length or accept-ranges", () => {
|
||||
it("should return a 200 with the data, without adding the undefined headers", async () => {
|
||||
@@ -252,6 +301,7 @@ describe("server", () => {
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
@@ -280,6 +330,7 @@ describe("server", () => {
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
@@ -308,6 +359,7 @@ describe("server", () => {
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
@@ -344,6 +396,7 @@ describe("server", () => {
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
@@ -382,6 +435,7 @@ describe("server", () => {
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
@@ -422,6 +476,7 @@ describe("server", () => {
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
musicLibrary.stream.mockResolvedValue(stream);
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/stream/track/${trackId}`)
|
||||
|
||||
Reference in New Issue
Block a user