Ability to list tracks on an album

This commit is contained in:
simojenki
2021-03-08 11:26:24 +11:00
parent 07b00f00f2
commit 081819f12b
8 changed files with 361 additions and 76 deletions

View File

@@ -34,6 +34,12 @@ export type Images = {
large: string | undefined; large: string | undefined;
}; };
export const NO_IMAGES: Images = {
small: undefined,
medium: undefined,
large: undefined
}
export type Artist = ArtistSummary & { export type Artist = ArtistSummary & {
albums: AlbumSummary[]; albums: AlbumSummary[];
}; };
@@ -46,7 +52,6 @@ export type AlbumSummary = {
}; };
export type Album = AlbumSummary & { export type Album = AlbumSummary & {
tracks: Track[]
}; };
export type Track = { export type Track = {
@@ -54,6 +59,10 @@ export type Track = {
name: string; name: string;
mimeType: string; mimeType: string;
duration: string; duration: string;
number: string | undefined;
genre: string | undefined;
album: AlbumSummary;
artist: ArtistSummary
}; };
export type Paging = { export type Paging = {
@@ -114,5 +123,6 @@ export interface MusicLibrary {
artist(id: string): Promise<Artist>; artist(id: string): Promise<Artist>;
albums(q: AlbumQuery): Promise<Result<AlbumSummary>>; albums(q: AlbumQuery): Promise<Result<AlbumSummary>>;
album(id: string): Promise<Album>; album(id: string): Promise<Album>;
tracks(albumId: string): Promise<Track[]>;
genres(): Promise<string[]>; genres(): Promise<string[]>;
} }

View File

@@ -16,6 +16,7 @@ import {
MusicLibrary, MusicLibrary,
Images, Images,
AlbumSummary, AlbumSummary,
NO_IMAGES,
} from "./music_service"; } from "./music_service";
import X2JS from "x2js"; import X2JS from "x2js";
@@ -58,7 +59,7 @@ export type artistSummary = {
_name: string; _name: string;
_albumCount: string; _albumCount: string;
_artistImageUrl: string | undefined; _artistImageUrl: string | undefined;
} };
export type GetArtistsResponse = SubsonicResponse & { export type GetArtistsResponse = SubsonicResponse & {
artists: { artists: {
@@ -118,31 +119,33 @@ export type GetArtistResponse = SubsonicResponse & {
}; };
export type song = { export type song = {
"_id": string, _id: string;
"_parent": string, _parent: string;
"_title": string, _title: string;
"_album": string, _album: string;
"_artist": string, _artist: string;
"_coverArt": string, _track: string;
"_created": "2004-11-08T23:36:11", _genre: string;
"_duration": string, _coverArt: string;
"_bitRate": "128", _created: "2004-11-08T23:36:11";
"_suffix": "mp3", _duration: string;
"_contentType": string, _bitRate: "128";
"_albumId": string, _suffix: "mp3";
"_artistId": string, _contentType: string;
"_type": "music" _albumId: string;
} _artistId: string;
_type: "music";
};
export type GetAlbumResponse = { export type GetAlbumResponse = {
album: { album: {
_id: string, _id: string;
_name: string, _name: string;
_genre: string, _genre: string;
_year: string, _year: string;
song: song[] song: song[];
} };
} };
export function isError( export function isError(
subsonicResponse: SubsonicResponse subsonicResponse: SubsonicResponse
@@ -329,29 +332,57 @@ export class Navidrome implements MusicService {
total: Math.min(MAX_ALBUM_LIST, total), total: Math.min(MAX_ALBUM_LIST, total),
})); }));
}, },
album: (id: string): Promise<Album> => navidrome album: (id: string): Promise<Album> =>
navidrome
.get<GetAlbumResponse>(credentials, "/rest/getAlbum", { id }) .get<GetAlbumResponse>(credentials, "/rest/getAlbum", { id })
.then(it => it.album) .then((it) => it.album)
.then(album => ({ .then((album) => ({
id: album._id, id: album._id,
name: album._name, name: album._name,
year: album._year, year: album._year,
genre: album._genre, genre: album._genre,
tracks: album.song.map(track => ({ // tracks: album.song.map(track => ({
id: track._id, // id: track._id,
name: track._title, // name: track._title,
mimeType: track._contentType, // mimeType: track._contentType,
duration: track._duration, // duration: track._duration,
})) // }))
})), })),
genres: () => genres: () =>
navidrome navidrome
.get<GenGenresResponse>(credentials, "/rest/getGenres") .get<GenGenresResponse>(credentials, "/rest/getGenres")
.then((it) => pipe( .then((it) =>
pipe(
it.genres.genre, it.genres.genre,
A.map(it => it.__text), A.map((it) => it.__text),
A.sort(ordString) A.sort(ordString)
)), )
),
tracks: (albumId: string) =>
navidrome
.get<GetAlbumResponse>(credentials, "/rest/getAlbum", { id: albumId })
.then((it) => it.album)
.then((album) =>
album.song.map((song) => ({
id: song._id,
name: song._title,
mimeType: song._contentType,
duration: song._duration,
number: song._track,
genre: song._genre,
album: {
id: album._id,
name: album._name,
year: album._year,
genre: album._genre,
},
artist: {
id: song._artistId,
name: song._artist,
image: NO_IMAGES,
},
}))
),
}; };
return Promise.resolve(musicLibrary); return Promise.resolve(musicLibrary);

View File

@@ -11,6 +11,7 @@ import {
MusicLibrary, MusicLibrary,
MusicService, MusicService,
slice2, slice2,
Track,
} from "./music_service"; } from "./music_service";
export const LOGIN_ROUTE = "/login"; export const LOGIN_ROUTE = "/login";
@@ -168,7 +169,7 @@ const genre = (genre: string) => ({
itemType: "container", itemType: "container",
id: `genre:${genre}`, id: `genre:${genre}`,
title: genre, title: genre,
}) });
const album = (album: AlbumSummary) => ({ const album = (album: AlbumSummary) => ({
itemType: "album", itemType: "album",
@@ -182,6 +183,27 @@ const album = (album: AlbumSummary) => ({
// } // }
}); });
const track = (track: Track) => ({
itemType: "track",
id: `track:${track.id}`,
mimeType: track.mimeType,
title: track.name,
trackMetadata: {
album: track.album.name,
albumId: track.album.id,
albumArtist: track.artist.name,
albumArtistId: track.artist.id,
// albumArtURI
artist: track.artist.name,
artistId: track.artist.id,
duration: track.duration,
genre: track.album.genre,
// genreId
trackNumber: track.number,
},
});
type SoapyHeaders = { type SoapyHeaders = {
credentials?: Credentials; credentials?: Credentials;
}; };
@@ -289,7 +311,18 @@ function bindSmapiSoapServiceToExpress(
.then(([page, total]) => .then(([page, total]) =>
getMetadataResult({ getMetadataResult({
mediaCollection: page.map(album), mediaCollection: page.map(album),
index: 0, index: paging._index,
total,
})
);
case "album":
return await musicLibrary
.tracks(typeId!)
.then(slice2(paging))
.then(([page, total]) =>
getMetadataResult({
mediaCollection: page.map(track),
index: paging._index,
total, total,
}) })
); );

View File

@@ -83,6 +83,9 @@ export function anArtist(fields: Partial<Artist> = {}): Artist {
}; };
} }
export const SAMPLE_GENRES = ["Metal", "Pop", "Rock", "Hip-Hop"]
export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)]
export function aTrack(fields: Partial<Track> = {}): Track { export function aTrack(fields: Partial<Track> = {}): Track {
const id = uuid(); const id = uuid();
return { return {
@@ -90,19 +93,21 @@ export function aTrack(fields: Partial<Track> = {}): Track {
name: `Track ${id}`, name: `Track ${id}`,
mimeType: `audio/mp3-${id}`, mimeType: `audio/mp3-${id}`,
duration: `${randomInt(500)}`, duration: `${randomInt(500)}`,
number: `${randomInt(100)}`,
genre: randomGenre(),
artist: anArtist(),
album: anAlbum(),
...fields ...fields
} }
} }
export function anAlbum(fields: Partial<Album> = {}): Album { export function anAlbum(fields: Partial<Album> = {}): Album {
const genres = ["Metal", "Pop", "Rock", "Hip-Hop"];
const id = uuid(); const id = uuid();
return { return {
id, id,
name: `Album ${id}`, name: `Album ${id}`,
genre: genres[randomInt(genres.length)], genre: randomGenre(),
year: `19${randomInt(99)}`, year: `19${randomInt(99)}`,
tracks: [aTrack(), aTrack(), aTrack()],
...fields, ...fields,
}; };
} }

View File

@@ -6,7 +6,7 @@ import {
albumToAlbumSummary, albumToAlbumSummary,
} from "../src/music_service"; } from "../src/music_service";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { anArtist, anAlbum } from "./builders"; import { anArtist, anAlbum, aTrack } from "./builders";
describe("InMemoryMusicService", () => { describe("InMemoryMusicService", () => {
const service = new InMemoryMusicService(); const service = new InMemoryMusicService();
@@ -176,6 +176,34 @@ describe("InMemoryMusicService", () => {
}); });
}); });
describe("tracks", () => {
const artist1Album1 = anAlbum();
const artist1Album2 = anAlbum();
const artist1 = anArtist({ albums: [artist1Album1, artist1Album2] });
const track1 = aTrack({ album: artist1Album1, artist: artist1 });
const track2 = aTrack({ album: artist1Album1, artist: artist1 });
const track3 = aTrack({ album: artist1Album2, artist: artist1 });
const track4 = aTrack({ album: artist1Album2, artist: artist1 });
beforeEach(() => {
service.hasArtists(artist1);
service.hasTracks(track1, track2, track3, track4);
});
describe("fetching tracks for an album", () => {
it("should return only tracks on that album", async () => {
expect(await musicLibrary.tracks(artist1Album1.id)).toEqual([track1, track2])
});
});
describe("fetching tracks for an album that doesnt exist", () => {
it("should return empty array", async () => {
expect(await musicLibrary.tracks("non existant album id")).toEqual([])
});
});
});
describe("albums", () => { describe("albums", () => {
const artist1_album1 = anAlbum({ genre: "Pop" }); const artist1_album1 = anAlbum({ genre: "Pop" });
const artist1_album2 = anAlbum({ genre: "Rock" }); const artist1_album2 = anAlbum({ genre: "Rock" });
@@ -332,8 +360,20 @@ describe("InMemoryMusicService", () => {
}); });
describe("genres", () => { describe("genres", () => {
const artist1 = anArtist({ albums: [anAlbum({ genre: "Pop" }), anAlbum({ genre: "Rock" }), anAlbum({ genre: "Pop" })] }); const artist1 = anArtist({
const artist2 = anArtist({ albums: [anAlbum({ genre: "Hip-Hop" }), anAlbum({ genre: "Rap" }), anAlbum({ genre: "Pop" })] }); albums: [
anAlbum({ genre: "Pop" }),
anAlbum({ genre: "Rock" }),
anAlbum({ genre: "Pop" }),
],
});
const artist2 = anArtist({
albums: [
anAlbum({ genre: "Hip-Hop" }),
anAlbum({ genre: "Rap" }),
anAlbum({ genre: "Pop" }),
],
});
beforeEach(() => { beforeEach(() => {
service.hasArtists(artist1, artist2); service.hasArtists(artist1, artist2);
@@ -341,13 +381,11 @@ describe("InMemoryMusicService", () => {
describe("fetching all in one page", () => { describe("fetching all in one page", () => {
it("should provide an array of artists", async () => { it("should provide an array of artists", async () => {
expect( expect(await musicLibrary.genres()).toEqual([
await musicLibrary.genres()
).toEqual([
"Hip-Hop", "Hip-Hop",
"Pop", "Pop",
"Rap", "Rap",
"Rock" "Rock",
]); ]);
}); });
}); });

View File

@@ -18,6 +18,7 @@ import {
artistToArtistSummary, artistToArtistSummary,
albumToAlbumSummary, albumToAlbumSummary,
Album, Album,
Track,
} from "../src/music_service"; } from "../src/music_service";
type P<T> = (t: T) => boolean; type P<T> = (t: T) => boolean;
@@ -29,6 +30,7 @@ const albumWithGenre = (genre: string): P<[Artist, Album]> => ([_, album]) =>
export class InMemoryMusicService implements MusicService { export class InMemoryMusicService implements MusicService {
users: Record<string, string> = {}; users: Record<string, string> = {};
artists: Artist[] = []; artists: Artist[] = [];
tracks: Track[] = [];
generateToken({ generateToken({
username, username,
@@ -101,6 +103,7 @@ export class InMemoryMusicService implements MusicService {
A.sort(ordString) A.sort(ordString)
) )
), ),
tracks: (albumId: string) => Promise.resolve(this.tracks.filter(it => it.album.id === albumId))
}); });
} }
@@ -119,9 +122,15 @@ export class InMemoryMusicService implements MusicService {
return this; return this;
} }
hasTracks(...newTracks: Track[]) {
this.tracks = [...this.tracks, ...newTracks];
return this;
}
clear() { clear() {
this.users = {}; this.users = {};
this.artists = []; this.artists = [];
this.tracks = [];
return this; return this;
} }
} }

View File

@@ -16,7 +16,9 @@ import {
range, range,
asArtistAlbumPairs, asArtistAlbumPairs,
Track, Track,
AlbumSummary AlbumSummary,
artistToArtistSummary,
NO_IMAGES
} from "../src/music_service"; } from "../src/music_service";
import { anAlbum, anArtist, aTrack } from "./builders"; import { anAlbum, anArtist, aTrack } from "./builders";
@@ -81,14 +83,16 @@ const albumXml = (artist: Artist, album: AlbumSummary, tracks: Track[] = []) =>
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">${tracks.map(track => songXml(artist, album, track))}</album>`; isVideo="false">${tracks.map(track => songXml(track))}</album>`;
const songXml = (artist: Artist, album: AlbumSummary, track: Track) => `<song const songXml = (track: Track) => `<song
id="${track.id}" id="${track.id}"
parent="${album.id}" parent="${track.album.id}"
title="${track.name}" title="${track.name}"
album="${album.name}" album="${track.album.name}"
artist="${artist.name}" artist="${track.artist.name}"
track="${track.number}"
genre="${track.genre}"
isDir="false" isDir="false"
coverArt="71381" coverArt="71381"
created="2004-11-08T23:36:11" created="2004-11-08T23:36:11"
@@ -99,8 +103,8 @@ const songXml = (artist: Artist, album: AlbumSummary, track: Track) => `<song
contentType="${track.mimeType}" contentType="${track.mimeType}"
isVideo="false" isVideo="false"
path="ACDC/High voltage/ACDC - The Jack.mp3" path="ACDC/High voltage/ACDC - The Jack.mp3"
albumId="${album.id}" albumId="${track.album.id}"
artistId="${artist.name}" artistId="${track.artist.id}"
type="music"/>`; type="music"/>`;
const albumListXml = ( const albumListXml = (
@@ -133,8 +137,8 @@ const genresXml = (
</genres> </genres>
</subsonic-response>`; </subsonic-response>`;
const getAlbumXml = (artist: Artist, album: Album) => `<subsonic-response status="ok" version="1.8.0"> const getAlbumXml = (artist: Artist, album: Album, tracks: Track[]) => `<subsonic-response status="ok" version="1.8.0">
${albumXml(artist, album, album.tracks)} ${albumXml(artist, album, tracks)}
</subsonic-response>` </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>`;
@@ -624,22 +628,24 @@ describe("Navidrome", () => {
describe("getting an album", () => { describe("getting an album", () => {
describe("when it exists", () => { describe("when it exists", () => {
const album = anAlbum({ tracks: [ const album = anAlbum();
aTrack(),
aTrack(),
aTrack(),
aTrack(),
] });
const artist = anArtist({ albums: [album] }) const artist = anArtist({ albums: [album] })
const tracks = [
aTrack({ artist, album }),
aTrack({ artist, album }),
aTrack({ artist, album }),
aTrack({ artist, album }),
]
beforeEach(() => { beforeEach(() => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve( Promise.resolve(
ok( ok(
getAlbumXml(artist, album) getAlbumXml(artist, album, tracks)
) )
) )
); );
@@ -663,4 +669,55 @@ describe("Navidrome", () => {
}); });
}); });
}); });
describe("getting tracks", () => {
describe("for an album", () => {
describe("when it exists", () => {
const album = anAlbum({ id: "album1", name: "Burnin" });
const albumSummary = albumToAlbumSummary(album);
const artist = anArtist({ id: "artist1", name: "Bob Marley", albums: [album] })
const artistSummary = {
...artistToArtistSummary(artist),
image: NO_IMAGES
};
const tracks = [
aTrack({ artist: artistSummary, album: albumSummary }),
aTrack({ artist: artistSummary, album: albumSummary }),
aTrack({ artist: artistSummary, album: albumSummary }),
aTrack({ artist: artistSummary, album: albumSummary }),
]
beforeEach(() => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(
ok(
getAlbumXml(artist, album, tracks)
)
)
);
});
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.tracks(album.id));
expect(result).toEqual(tracks);
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, {
params: {
id: album.id,
...authParams,
},
});
});
});
});
});
}); });

View File

@@ -14,6 +14,7 @@ import {
someCredentials, someCredentials,
anArtist, anArtist,
anAlbum, anAlbum,
aTrack,
} from "./builders"; } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryMusicService } from "./in_memory_music_service";
import supersoap from "./supersoap"; import supersoap from "./supersoap";
@@ -470,7 +471,7 @@ describe("api", () => {
id: `album:${it.id}`, id: `album:${it.id}`,
title: it.name, title: it.name,
})), })),
index: 0, index: 2,
total: artistWithManyAlbums.albums.length, total: artistWithManyAlbums.albums.length,
}) })
); );
@@ -631,6 +632,107 @@ describe("api", () => {
}); });
}); });
}); });
describe("asking for tracks", () => {
describe("for an album", () => {
const album = anAlbum();
const artist = anArtist({
albums: [album],
});
const track1 = aTrack({ artist, album, number: "1" });
const track2 = aTrack({ artist, album, number: "2" });
const track3 = aTrack({ artist, album, number: "3" });
const track4 = aTrack({ artist, album, number: "4" });
const track5 = aTrack({ artist, album, number: "5" });
beforeEach(() => {
musicService.hasArtists(artist);
musicService.hasTracks(track1, track2, track3, track4, track5);
});
describe("asking for all albums", () => {
it("should return them all", async () => {
const result = await ws.getMetadataAsync({
id: `album:${album.id}`,
index: 0,
count: 100,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [
track1,
track2,
track3,
track4,
track5,
].map((track) => ({
itemType: "track",
id: `track:${track.id}`,
mimeType: track.mimeType,
title: track.name,
trackMetadata: {
album: track.album.name,
albumId: track.album.id,
albumArtist: track.artist.name,
albumArtistId: track.artist.id,
// albumArtURI
artist: track.artist.name,
artistId: track.artist.id,
duration: track.duration,
genre: track.album.genre,
// genreId
trackNumber: track.number,
},
})),
index: 0,
total: 5,
})
);
});
});
describe("asking for a single page of tracks", () => {
it("should return only that page", async () => {
const result = await ws.getMetadataAsync({
id: `album:${album.id}`,
index: 2,
count: 2,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [
track3,
track4,
].map((track) => ({
itemType: "track",
id: `track:${track.id}`,
mimeType: track.mimeType,
title: track.name,
trackMetadata: {
album: track.album.name,
albumId: track.album.id,
albumArtist: track.artist.name,
albumArtistId: track.artist.id,
// albumArtURI
artist: track.artist.name,
artistId: track.artist.id,
duration: track.duration,
genre: track.album.genre,
// genreId
trackNumber: track.number,
},
})),
index: 2,
total: 5,
})
);
});
});
});
});
}); });
}); });
}); });