mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Merge pull request #1 from simojenki/feature/albums
Ability to query navidrome for an album
This commit is contained in:
@@ -35,7 +35,7 @@ export type Images = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Artist = ArtistSummary & {
|
export type Artist = ArtistSummary & {
|
||||||
albums: Album[];
|
albums: AlbumSummary[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AlbumSummary = {
|
export type AlbumSummary = {
|
||||||
@@ -45,7 +45,16 @@ export type AlbumSummary = {
|
|||||||
genre: string | undefined;
|
genre: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Album = AlbumSummary & {};
|
export type Album = AlbumSummary & {
|
||||||
|
tracks: Track[]
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Track = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
duration: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Paging = {
|
export type Paging = {
|
||||||
_index: number;
|
_index: number;
|
||||||
|
|||||||
@@ -53,18 +53,17 @@ export type album = {
|
|||||||
_coverArt: string;
|
_coverArt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type artist = {
|
export type artistSummary = {
|
||||||
_id: string;
|
_id: string;
|
||||||
_name: string;
|
_name: string;
|
||||||
_albumCount: string;
|
_albumCount: string;
|
||||||
_artistImageUrl: string | undefined;
|
_artistImageUrl: string | undefined;
|
||||||
album: album[];
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export type GetArtistsResponse = SubsonicResponse & {
|
export type GetArtistsResponse = SubsonicResponse & {
|
||||||
artists: {
|
artists: {
|
||||||
index: {
|
index: {
|
||||||
artist: artist[];
|
artist: artistSummary[];
|
||||||
_name: string;
|
_name: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
@@ -108,14 +107,43 @@ export type ArtistInfo = {
|
|||||||
image: Images;
|
image: Images;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetArtistInfoResponse = {
|
export type GetArtistInfoResponse = SubsonicResponse & {
|
||||||
artistInfo: artistInfo;
|
artistInfo: artistInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetArtistResponse = {
|
export type GetArtistResponse = SubsonicResponse & {
|
||||||
artist: artist;
|
artist: artistSummary & {
|
||||||
|
album: album[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type song = {
|
||||||
|
"_id": string,
|
||||||
|
"_parent": string,
|
||||||
|
"_title": string,
|
||||||
|
"_album": string,
|
||||||
|
"_artist": string,
|
||||||
|
"_coverArt": string,
|
||||||
|
"_created": "2004-11-08T23:36:11",
|
||||||
|
"_duration": string,
|
||||||
|
"_bitRate": "128",
|
||||||
|
"_suffix": "mp3",
|
||||||
|
"_contentType": string,
|
||||||
|
"_albumId": string,
|
||||||
|
"_artistId": string,
|
||||||
|
"_type": "music"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetAlbumResponse = {
|
||||||
|
album: {
|
||||||
|
_id: string,
|
||||||
|
_name: string,
|
||||||
|
_genre: string,
|
||||||
|
_year: string,
|
||||||
|
song: song[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isError(
|
export function isError(
|
||||||
subsonicResponse: SubsonicResponse
|
subsonicResponse: SubsonicResponse
|
||||||
): subsonicResponse is SubsonicError {
|
): subsonicResponse is SubsonicError {
|
||||||
@@ -211,7 +239,7 @@ export class Navidrome implements MusicService {
|
|||||||
getArtist = (
|
getArtist = (
|
||||||
credentials: Credentials,
|
credentials: Credentials,
|
||||||
id: string
|
id: string
|
||||||
): Promise<IdName & { albums: Album[] }> =>
|
): Promise<IdName & { albums: AlbumSummary[] }> =>
|
||||||
this.get<GetArtistResponse>(credentials, "/rest/getArtist", {
|
this.get<GetArtistResponse>(credentials, "/rest/getArtist", {
|
||||||
id,
|
id,
|
||||||
})
|
})
|
||||||
@@ -301,9 +329,21 @@ export class Navidrome implements MusicService {
|
|||||||
total: Math.min(MAX_ALBUM_LIST, total),
|
total: Math.min(MAX_ALBUM_LIST, total),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
album: (_: string): Promise<Album> => {
|
album: (id: string): Promise<Album> => navidrome
|
||||||
return Promise.reject("not implemented");
|
.get<GetAlbumResponse>(credentials, "/rest/getAlbum", { id })
|
||||||
},
|
.then(it => it.album)
|
||||||
|
.then(album => ({
|
||||||
|
id: album._id,
|
||||||
|
name: album._name,
|
||||||
|
year: album._year,
|
||||||
|
genre: album._genre,
|
||||||
|
tracks: album.song.map(track => ({
|
||||||
|
id: track._id,
|
||||||
|
name: track._title,
|
||||||
|
mimeType: track._contentType,
|
||||||
|
duration: track._duration,
|
||||||
|
}))
|
||||||
|
})),
|
||||||
genres: () =>
|
genres: () =>
|
||||||
navidrome
|
navidrome
|
||||||
.get<GenGenresResponse>(credentials, "/rest/getGenres")
|
.get<GenGenresResponse>(credentials, "/rest/getGenres")
|
||||||
|
|||||||
@@ -280,8 +280,8 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
index: paging._index,
|
index: paging._index,
|
||||||
total,
|
total,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
case "artist":
|
case "artist":
|
||||||
return await musicLibrary
|
return await musicLibrary
|
||||||
.artist(typeId!)
|
.artist(typeId!)
|
||||||
.then((artist) => artist.albums)
|
.then((artist) => artist.albums)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { v4 as uuid } from "uuid";
|
|||||||
import { Credentials } from "../src/smapi";
|
import { Credentials } from "../src/smapi";
|
||||||
|
|
||||||
import { Service, Device } from "../src/sonos";
|
import { Service, Device } from "../src/sonos";
|
||||||
import { Album, Artist } from "../src/music_service";
|
import { Album, Artist, Track } from "../src/music_service";
|
||||||
|
|
||||||
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
|
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
|
||||||
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
||||||
@@ -83,6 +83,17 @@ export function anArtist(fields: Partial<Artist> = {}): Artist {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function aTrack(fields: Partial<Track> = {}): Track {
|
||||||
|
const id = uuid();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: `Track ${id}`,
|
||||||
|
mimeType: `audio/mp3-${id}`,
|
||||||
|
duration: `${randomInt(500)}`,
|
||||||
|
...fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function anAlbum(fields: Partial<Album> = {}): Album {
|
export function anAlbum(fields: Partial<Album> = {}): Album {
|
||||||
const genres = ["Metal", "Pop", "Rock", "Hip-Hop"];
|
const genres = ["Metal", "Pop", "Rock", "Hip-Hop"];
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
@@ -91,6 +102,7 @@ export function anAlbum(fields: Partial<Album> = {}): Album {
|
|||||||
name: `Album ${id}`,
|
name: `Album ${id}`,
|
||||||
genre: genres[randomInt(genres.length)],
|
genre: genres[randomInt(genres.length)],
|
||||||
year: `19${randomInt(99)}`,
|
year: `19${randomInt(99)}`,
|
||||||
|
tracks: [aTrack(), aTrack(), aTrack()],
|
||||||
...fields,
|
...fields,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -123,9 +135,9 @@ export const BOB_MARLEY: Artist = {
|
|||||||
id: uuid(),
|
id: uuid(),
|
||||||
name: "Bob Marley",
|
name: "Bob Marley",
|
||||||
albums: [
|
albums: [
|
||||||
{ id: uuid(), name: "Burin'", year: "1973", genre: "Reggae" },
|
{ id: uuid(), name: "Burin'", year: "1973", genre: "Reggae", },
|
||||||
{ id: uuid(), name: "Exodus", year: "1977", genre: "Reggae" },
|
{ id: uuid(), name: "Exodus", year: "1977", genre: "Reggae", },
|
||||||
{ id: uuid(), name: "Kaya", year: "1978", genre: "Ska" },
|
{ id: uuid(), name: "Kaya", year: "1978", genre: "Ska", },
|
||||||
],
|
],
|
||||||
image: {
|
image: {
|
||||||
small: "http://localhost/BOB_MARLEY/sml",
|
small: "http://localhost/BOB_MARLEY/sml",
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import {
|
|||||||
albumToAlbumSummary,
|
albumToAlbumSummary,
|
||||||
range,
|
range,
|
||||||
asArtistAlbumPairs,
|
asArtistAlbumPairs,
|
||||||
|
Track,
|
||||||
|
AlbumSummary
|
||||||
} from "../src/music_service";
|
} from "../src/music_service";
|
||||||
import { anAlbum, anArtist } from "./builders";
|
import { anAlbum, anArtist, aTrack } from "./builders";
|
||||||
|
|
||||||
jest.mock("../src/random_string");
|
jest.mock("../src/random_string");
|
||||||
|
|
||||||
@@ -66,7 +68,7 @@ const artistInfoXml = (
|
|||||||
</artistInfo>
|
</artistInfo>
|
||||||
</subsonic-response>`;
|
</subsonic-response>`;
|
||||||
|
|
||||||
const albumXml = (artist: Artist, album: Album) => `<album id="${album.id}"
|
const albumXml = (artist: Artist, album: AlbumSummary, tracks: Track[] = []) => `<album id="${album.id}"
|
||||||
parent="${artist.id}"
|
parent="${artist.id}"
|
||||||
isDir="true"
|
isDir="true"
|
||||||
title="${album.name}" name="${album.name}" album="${album.name}"
|
title="${album.name}" name="${album.name}" album="${album.name}"
|
||||||
@@ -79,24 +81,43 @@ const albumXml = (artist: Artist, album: Album) => `<album id="${album.id}"
|
|||||||
created="2021-01-07T08:19:55.834207205Z"
|
created="2021-01-07T08:19:55.834207205Z"
|
||||||
artistId="${artist.id}"
|
artistId="${artist.id}"
|
||||||
songCount="19"
|
songCount="19"
|
||||||
isVideo="false"></album>`;
|
isVideo="false">${tracks.map(track => songXml(artist, album, track))}</album>`;
|
||||||
|
|
||||||
|
const songXml = (artist: Artist, album: AlbumSummary, track: Track) => `<song
|
||||||
|
id="${track.id}"
|
||||||
|
parent="${album.id}"
|
||||||
|
title="${track.name}"
|
||||||
|
album="${album.name}"
|
||||||
|
artist="${artist.name}"
|
||||||
|
isDir="false"
|
||||||
|
coverArt="71381"
|
||||||
|
created="2004-11-08T23:36:11"
|
||||||
|
duration="${track.duration}"
|
||||||
|
bitRate="128"
|
||||||
|
size="5624132"
|
||||||
|
suffix="mp3"
|
||||||
|
contentType="${track.mimeType}"
|
||||||
|
isVideo="false"
|
||||||
|
path="ACDC/High voltage/ACDC - The Jack.mp3"
|
||||||
|
albumId="${album.id}"
|
||||||
|
artistId="${artist.name}"
|
||||||
|
type="music"/>`;
|
||||||
|
|
||||||
const albumListXml = (
|
const albumListXml = (
|
||||||
albums: [Artist, Album][]
|
albums: [Artist, Album][]
|
||||||
) => `<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)">
|
||||||
<albumList>
|
<albumList>
|
||||||
${albums.map(([artist, album]) =>
|
${albums.map(([artist, album]) =>
|
||||||
albumXml(artist, album)
|
albumXml(artist, album)
|
||||||
)}
|
)}
|
||||||
</albumList>
|
</albumList>
|
||||||
</subsonic-response>`;
|
</subsonic-response>`;
|
||||||
|
|
||||||
const artistXml = (
|
const artistXml = (
|
||||||
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)">
|
||||||
<artist id="${artist.id}" name="${artist.name}" albumCount="${
|
<artist id="${artist.id}" name="${artist.name}" albumCount="${artist.albums.length
|
||||||
artist.albums.length
|
}" artistImageUrl="....">
|
||||||
}" artistImageUrl="....">
|
|
||||||
${artist.albums.map((album) => albumXml(artist, album))}
|
${artist.albums.map((album) => albumXml(artist, album))}
|
||||||
</artist>
|
</artist>
|
||||||
</subsonic-response>`;
|
</subsonic-response>`;
|
||||||
@@ -106,12 +127,16 @@ const genresXml = (
|
|||||||
) => `<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)">
|
||||||
<genres>
|
<genres>
|
||||||
${genres.map(
|
${genres.map(
|
||||||
(it) =>
|
(it) =>
|
||||||
`<genre songCount="1475" albumCount="86">${it}</genre>`
|
`<genre songCount="1475" albumCount="86">${it}</genre>`
|
||||||
)}
|
)}
|
||||||
</genres>
|
</genres>
|
||||||
</subsonic-response>`;
|
</subsonic-response>`;
|
||||||
|
|
||||||
|
const getAlbumXml = (artist: Artist, album: Album) => `<subsonic-response status="ok" version="1.8.0">
|
||||||
|
${albumXml(artist, album, album.tracks)}
|
||||||
|
</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", () => {
|
||||||
@@ -184,7 +209,7 @@ describe("Navidrome", () => {
|
|||||||
.mockImplementationOnce(() => Promise.resolve(ok(genresXml(genres))));
|
.mockImplementationOnce(() => Promise.resolve(ok(genresXml(genres))));
|
||||||
});
|
});
|
||||||
|
|
||||||
it.only("should return them alphabetically sorted", async () => {
|
it("should return them alphabetically sorted", async () => {
|
||||||
const result = await navidrome
|
const result = await navidrome
|
||||||
.generateToken({ username, password })
|
.generateToken({ username, password })
|
||||||
.then((it) => it as AuthSuccess)
|
.then((it) => it as AuthSuccess)
|
||||||
@@ -504,7 +529,7 @@ describe("Navidrome", () => {
|
|||||||
.then((it) => it.albums(paging));
|
.then((it) => it.albums(paging));
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
results: albums.map(albumToAlbumSummary),
|
results: albums,
|
||||||
total: 6,
|
total: 6,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -596,4 +621,46 @@ describe("Navidrome", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getting an album", () => {
|
||||||
|
describe("when it exists", () => {
|
||||||
|
const album = anAlbum({ tracks: [
|
||||||
|
aTrack(),
|
||||||
|
aTrack(),
|
||||||
|
aTrack(),
|
||||||
|
aTrack(),
|
||||||
|
] });
|
||||||
|
|
||||||
|
const artist = anArtist({ albums: [album] })
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGET
|
||||||
|
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||||
|
.mockImplementationOnce(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
ok(
|
||||||
|
getAlbumXml(artist, album)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the album", async () => {
|
||||||
|
const result = await navidrome
|
||||||
|
.generateToken({ username, password })
|
||||||
|
.then((it) => it as AuthSuccess)
|
||||||
|
.then((it) => navidrome.login(it.authToken))
|
||||||
|
.then((it) => it.album(album.id));
|
||||||
|
|
||||||
|
expect(result).toEqual(album);
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, {
|
||||||
|
params: {
|
||||||
|
id: album.id,
|
||||||
|
...authParams,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user