Scrobbling on play

This commit is contained in:
simojenki
2021-03-17 18:40:24 +11:00
parent 5ee9dd5d5b
commit 19953bddcf
6 changed files with 230 additions and 40 deletions

View File

@@ -146,4 +146,5 @@ export interface MusicLibrary {
range: string | undefined; range: string | undefined;
}): Promise<Stream>; }): Promise<Stream>;
coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>; coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>;
scrobble(id: string): Promise<boolean>
} }

View File

@@ -118,12 +118,12 @@ export type artistInfo = {
smallImageUrl: string | undefined; smallImageUrl: string | undefined;
mediumImageUrl: string | undefined; mediumImageUrl: string | undefined;
largeImageUrl: string | undefined; largeImageUrl: string | undefined;
similarArtist: artistSummary[] similarArtist: artistSummary[];
}; };
export type ArtistInfo = { export type ArtistInfo = {
image: Images; image: Images;
similarArtist: {id:string, name:string}[] similarArtist: { id: string; name: string }[];
}; };
export type GetArtistInfoResponse = SubsonicResponse & { export type GetArtistInfoResponse = SubsonicResponse & {
@@ -244,6 +244,32 @@ export class Navidrome implements MusicService {
else return response; else return response;
}); });
post = async (
{ username, password }: Credentials,
path: string,
q: {} = {},
config: AxiosRequestConfig | undefined = {}
) =>
axios
.post(`${this.url}${path}`, {
params: {
...q,
u: username,
...t_and_s(password),
v: "1.16.1",
c: "bonob",
},
headers: {
"User-Agent": "bonob",
},
...config,
})
.then((response) => {
if (response.status != 200 && response.status != 206)
throw `Navidrome failed with a ${response.status}`;
else return response;
});
getJSON = async <T>( getJSON = async <T>(
{ username, password }: Credentials, { username, password }: Credentials,
path: string, path: string,
@@ -258,7 +284,7 @@ export class Navidrome implements MusicService {
"subsonic-response.albumList.album", "subsonic-response.albumList.album",
"subsonic-response.album.song", "subsonic-response.album.song",
"subsonic-response.genres.genre", "subsonic-response.genres.genre",
"subsonic-response.artistInfo.similarArtist" "subsonic-response.artistInfo.similarArtist",
], ],
}).xml2js(response.data) as SubconicEnvelope }).xml2js(response.data) as SubconicEnvelope
) )
@@ -305,7 +331,10 @@ export class Navidrome implements MusicService {
medium: validate(it.artistInfo.mediumImageUrl), medium: validate(it.artistInfo.mediumImageUrl),
large: validate(it.artistInfo.largeImageUrl), large: validate(it.artistInfo.largeImageUrl),
}, },
similarArtist: (it.artistInfo.similarArtist || []).map(artist => ({id: artist._id, name: artist._name})) similarArtist: (it.artistInfo.similarArtist || []).map((artist) => ({
id: artist._id,
name: artist._name,
})),
})); }));
getAlbum = (credentials: Credentials, id: string): Promise<Album> => getAlbum = (credentials: Credentials, id: string): Promise<Album> =>
@@ -346,7 +375,7 @@ export class Navidrome implements MusicService {
name: artist.name, name: artist.name,
image: artistInfo.image, image: artistInfo.image,
albums: artist.albums, albums: artist.albums,
similarArtists: artistInfo.similarArtist similarArtists: artistInfo.similarArtist,
})); }));
getCoverArt = (credentials: Credentials, id: string, size?: number) => getCoverArt = (credentials: Credentials, id: string, size?: number) =>
@@ -517,6 +546,11 @@ export class Navidrome implements MusicService {
}); });
} }
}, },
scrobble: async (id: string) =>
navidrome
.post(credentials, `/rest/scrobble`, { id })
.then((_) => true)
.catch(() => false),
}; };
return Promise.resolve(musicLibrary); return Promise.resolve(musicLibrary);

View File

@@ -14,6 +14,7 @@ import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
import { MusicService, isSuccess } from "./music_service"; import { MusicService, isSuccess } from "./music_service";
import bindSmapiSoapServiceToExpress from "./smapi"; import bindSmapiSoapServiceToExpress from "./smapi";
import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens"; import { AccessTokens, AccessTokenPerAuthToken } from "./access_tokens";
import logger from "./logger";
export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token"; export const BONOB_ACCESS_TOKEN_HEADER = "bonob-access-token";
@@ -136,6 +137,14 @@ function server(
} else { } else {
return musicService return musicService
.login(authToken) .login(authToken)
.then((it) =>
it.scrobble(id).then((scrobbleSuccess) => {
if(!scrobbleSuccess) {
logger.warn("Failed to scrobble....")
}
return it;
})
)
.then((it) => .then((it) =>
it.stream({ trackId: id, range: req.headers["range"] || undefined }) it.stream({ trackId: id, range: req.headers["range"] || undefined })
) )

View File

@@ -42,7 +42,9 @@ export class InMemoryMusicService implements MusicService {
this.users[username] == password this.users[username] == password
) { ) {
return Promise.resolve({ return Promise.resolve({
authToken: Buffer.from(JSON.stringify({ username, password })).toString('base64'), authToken: Buffer.from(JSON.stringify({ username, password })).toString(
"base64"
),
userId: username, userId: username,
nickname: username, nickname: username,
}); });
@@ -52,7 +54,9 @@ export class InMemoryMusicService implements MusicService {
} }
login(token: string): Promise<MusicLibrary> { 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) if (this.users[credentials.username] != credentials.password)
return Promise.reject("Invalid auth token"); return Promise.reject("Invalid auth token");
@@ -103,18 +107,24 @@ export class InMemoryMusicService implements MusicService {
A.sort(ordString) A.sort(ordString)
) )
), ),
tracks: (albumId: string) => Promise.resolve(this.tracks.filter(it => it.album.id === albumId)), tracks: (albumId: string) =>
track: (trackId: string) => pipe( Promise.resolve(this.tracks.filter((it) => it.album.id === albumId)),
this.tracks.find(it => it.id === trackId), track: (trackId: string) =>
O.fromNullable, pipe(
O.map(it => Promise.resolve(it)), this.tracks.find((it) => it.id === trackId),
O.getOrElse(() => Promise.reject(`Failed to find track with id ${trackId}`)) O.fromNullable,
), O.map((it) => Promise.resolve(it)),
stream: (_: { O.getOrElse(() =>
trackId: string; Promise.reject(`Failed to find track with id ${trackId}`)
range: string | undefined; )
}) => Promise.reject("unsupported operation"), ),
coverArt: (id: string, _: "album" | "artist", size?: number) => Promise.reject(`Cannot retrieve coverArt for ${id}, size ${size}`) 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);
},
}); });
} }

View File

@@ -68,7 +68,7 @@ const ok = (data: string) => ({
}); });
const artistInfoXml = ( 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)"> ) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<artistInfo> <artistInfo>
<biography></biography> <biography></biography>
@@ -77,7 +77,10 @@ const artistInfoXml = (
<smallImageUrl>${artist.image.small || ""}</smallImageUrl> <smallImageUrl>${artist.image.small || ""}</smallImageUrl>
<mediumImageUrl>${artist.image.medium || ""}</mediumImageUrl> <mediumImageUrl>${artist.image.medium || ""}</mediumImageUrl>
<largeImageUrl>${artist.image.large || ""}</largeImageUrl> <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> </artistInfo>
</subsonic-response>`; </subsonic-response>`;
@@ -173,6 +176,8 @@ const getSongXml = (
)} )}
</subsonic-response>`; </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>`; 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", () => { describe("Navidrome", () => {
@@ -185,12 +190,14 @@ describe("Navidrome", () => {
const mockedRandomString = (randomString as unknown) as jest.Mock; const mockedRandomString = (randomString as unknown) as jest.Mock;
const mockGET = jest.fn(); const mockGET = jest.fn();
const mockPOST = jest.fn();
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
jest.resetAllMocks(); jest.resetAllMocks();
axios.get = mockGET; axios.get = mockGET;
axios.post = mockPOST;
mockedRandomString.mockReturnValue(salt); mockedRandomString.mockReturnValue(salt);
}); });
@@ -310,7 +317,10 @@ describe("Navidrome", () => {
medium: `http://localhost:80/${DODGY_IMAGE_NAME}`, medium: `http://localhost:80/${DODGY_IMAGE_NAME}`,
large: `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(() => { beforeEach(() => {
@@ -340,7 +350,7 @@ describe("Navidrome", () => {
large: undefined, large: undefined,
}, },
albums: artist.albums, albums: artist.albums,
similarArtists: artist.similarArtists similarArtists: artist.similarArtists,
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -403,7 +413,7 @@ describe("Navidrome", () => {
large: undefined, large: undefined,
}, },
albums: artist.albums, albums: artist.albums,
similarArtists: artist.similarArtists similarArtists: artist.similarArtists,
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -422,7 +432,7 @@ describe("Navidrome", () => {
headers, headers,
}); });
}); });
}); });
describe("and has no similar artists", () => { describe("and has no similar artists", () => {
const album1: Album = anAlbum(); const album1: Album = anAlbum();
@@ -466,7 +476,7 @@ describe("Navidrome", () => {
large: undefined, large: undefined,
}, },
albums: artist.albums, albums: artist.albums,
similarArtists: artist.similarArtists similarArtists: artist.similarArtists,
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -485,7 +495,7 @@ describe("Navidrome", () => {
headers, headers,
}); });
}); });
}); });
describe("and has dodgy looking artist image uris", () => { describe("and has dodgy looking artist image uris", () => {
const album1: Album = anAlbum(); const album1: Album = anAlbum();
@@ -529,7 +539,7 @@ describe("Navidrome", () => {
large: undefined, large: undefined,
}, },
albums: artist.albums, albums: artist.albums,
similarArtists: [] similarArtists: [],
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -557,7 +567,7 @@ describe("Navidrome", () => {
const artist: Artist = anArtist({ const artist: Artist = anArtist({
albums: [album1, album2], albums: [album1, album2],
similarArtists: [] similarArtists: [],
}); });
beforeEach(() => { beforeEach(() => {
@@ -583,7 +593,7 @@ describe("Navidrome", () => {
name: artist.name, name: artist.name,
image: artist.image, image: artist.image,
albums: artist.albums, albums: artist.albums,
similarArtists: [] similarArtists: [],
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -609,7 +619,7 @@ describe("Navidrome", () => {
const artist: Artist = anArtist({ const artist: Artist = anArtist({
albums: [album], albums: [album],
similarArtists: [] similarArtists: [],
}); });
beforeEach(() => { beforeEach(() => {
@@ -635,7 +645,7 @@ describe("Navidrome", () => {
name: artist.name, name: artist.name,
image: artist.image, image: artist.image,
albums: artist.albums, albums: artist.albums,
similarArtists: [] similarArtists: [],
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -659,7 +669,7 @@ describe("Navidrome", () => {
describe("and has no albums", () => { describe("and has no albums", () => {
const artist: Artist = anArtist({ const artist: Artist = anArtist({
albums: [], albums: [],
similarArtists: [] similarArtists: [],
}); });
beforeEach(() => { beforeEach(() => {
@@ -685,7 +695,7 @@ describe("Navidrome", () => {
name: artist.name, name: artist.name,
image: artist.image, image: artist.image,
albums: [], albums: [],
similarArtists: [] similarArtists: [],
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -1662,7 +1672,7 @@ describe("Navidrome", () => {
const artist = anArtist({ const artist = anArtist({
id: artistId, id: artistId,
albums: [album1, album2], albums: [album1, album2],
image: images image: images,
}); });
mockGET mockGET
@@ -1737,7 +1747,11 @@ describe("Navidrome", () => {
data: Buffer.from("the image", "ascii"), data: Buffer.from("the image", "ascii"),
}; };
const artist = anArtist({ id: artistId, albums: [], image: images }); const artist = anArtist({
id: artistId,
albums: [],
image: images,
});
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
@@ -1879,7 +1893,7 @@ describe("Navidrome", () => {
const artist = anArtist({ const artist = anArtist({
id: artistId, id: artistId,
albums: [album1, album2], albums: [album1, album2],
image: images image: images,
}); });
mockGET mockGET
@@ -1955,7 +1969,11 @@ describe("Navidrome", () => {
data: Buffer.from("the image", "ascii"), data: Buffer.from("the image", "ascii"),
}; };
const artist = anArtist({ id: artistId, albums: [], image: images }); const artist = anArtist({
id: artistId,
albums: [],
image: images,
});
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
@@ -2022,7 +2040,7 @@ describe("Navidrome", () => {
const artist = anArtist({ const artist = anArtist({
id: artistId, id: artistId,
albums: [album1, album2], albums: [album1, album2],
image: images image: images,
}); });
mockGET mockGET
@@ -2098,7 +2116,11 @@ describe("Navidrome", () => {
data: Buffer.from("the image", "ascii"), data: Buffer.from("the image", "ascii"),
}; };
const artist = anArtist({ id: artistId, albums: [], image: images }); const artist = anArtist({
id: artistId,
albums: [],
image: images,
});
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .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,
});
});
});
});
}); });

View File

@@ -198,6 +198,7 @@ describe("server", () => {
}; };
const musicLibrary = { const musicLibrary = {
stream: jest.fn(), stream: jest.fn(),
scrobble: jest.fn()
}; };
let now = dayjs(); let now = dayjs();
const accessTokens = new ExpiringAccessTokens({ now: () => now }); 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 sonos does not ask for a range", () => {
describe("when the music service does not return a content-range, content-length or accept-ranges", () => { 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 () => { it("should return a 200 with the data, without adding the undefined headers", async () => {
@@ -252,6 +301,7 @@ describe("server", () => {
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)
@@ -280,6 +330,7 @@ describe("server", () => {
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)
@@ -308,6 +359,7 @@ describe("server", () => {
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)
@@ -344,6 +396,7 @@ describe("server", () => {
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)
@@ -382,6 +435,7 @@ describe("server", () => {
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)
@@ -422,6 +476,7 @@ describe("server", () => {
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.stream.mockResolvedValue(stream); musicLibrary.stream.mockResolvedValue(stream);
musicLibrary.scrobble.mockResolvedValue(true);
const res = await request(server) const res = await request(server)
.get(`/stream/track/${trackId}`) .get(`/stream/track/${trackId}`)