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;
}): Promise<Stream>;
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;
mediumImageUrl: string | undefined;
largeImageUrl: string | undefined;
similarArtist: artistSummary[]
similarArtist: artistSummary[];
};
export type ArtistInfo = {
image: Images;
similarArtist: {id:string, name:string}[]
similarArtist: { id: string; name: string }[];
};
export type GetArtistInfoResponse = SubsonicResponse & {
@@ -244,6 +244,32 @@ export class Navidrome implements MusicService {
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>(
{ username, password }: Credentials,
path: string,
@@ -258,7 +284,7 @@ export class Navidrome implements MusicService {
"subsonic-response.albumList.album",
"subsonic-response.album.song",
"subsonic-response.genres.genre",
"subsonic-response.artistInfo.similarArtist"
"subsonic-response.artistInfo.similarArtist",
],
}).xml2js(response.data) as SubconicEnvelope
)
@@ -305,7 +331,10 @@ export class Navidrome implements MusicService {
medium: validate(it.artistInfo.mediumImageUrl),
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> =>
@@ -346,7 +375,7 @@ export class Navidrome implements MusicService {
name: artist.name,
image: artistInfo.image,
albums: artist.albums,
similarArtists: artistInfo.similarArtist
similarArtists: artistInfo.similarArtist,
}));
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);

View File

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

View File

@@ -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),
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}`))
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}`)
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 = (
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`, {
@@ -466,7 +476,7 @@ describe("Navidrome", () => {
large: undefined,
},
albums: artist.albums,
similarArtists: artist.similarArtists
similarArtists: artist.similarArtists,
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -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,
});
});
});
});
});

View File

@@ -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}`)