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:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
|
||||
@@ -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`, {
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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