From 081819f12b04d84fd54f0e1e01ba8e03ce70bf64 Mon Sep 17 00:00:00 2001 From: simojenki Date: Mon, 8 Mar 2021 11:26:24 +1100 Subject: [PATCH] Ability to list tracks on an album --- src/music_service.ts | 12 ++- src/navidrome.ts | 117 ++++++++++++++++---------- src/smapi.ts | 41 ++++++++- tests/builders.ts | 11 ++- tests/in_memory_music_service.test.ts | 52 ++++++++++-- tests/in_memory_music_service.ts | 9 ++ tests/navidrome.test.ts | 91 ++++++++++++++++---- tests/smapi.test.ts | 104 ++++++++++++++++++++++- 8 files changed, 361 insertions(+), 76 deletions(-) diff --git a/src/music_service.ts b/src/music_service.ts index 6bb8cbc..31066e8 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -34,6 +34,12 @@ export type Images = { large: string | undefined; }; +export const NO_IMAGES: Images = { + small: undefined, + medium: undefined, + large: undefined +} + export type Artist = ArtistSummary & { albums: AlbumSummary[]; }; @@ -46,7 +52,6 @@ export type AlbumSummary = { }; export type Album = AlbumSummary & { - tracks: Track[] }; export type Track = { @@ -54,6 +59,10 @@ export type Track = { name: string; mimeType: string; duration: string; + number: string | undefined; + genre: string | undefined; + album: AlbumSummary; + artist: ArtistSummary }; export type Paging = { @@ -114,5 +123,6 @@ export interface MusicLibrary { artist(id: string): Promise; albums(q: AlbumQuery): Promise>; album(id: string): Promise; + tracks(albumId: string): Promise; genres(): Promise; } diff --git a/src/navidrome.ts b/src/navidrome.ts index ef5e924..2230b82 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -16,6 +16,7 @@ import { MusicLibrary, Images, AlbumSummary, + NO_IMAGES, } from "./music_service"; import X2JS from "x2js"; @@ -58,7 +59,7 @@ export type artistSummary = { _name: string; _albumCount: string; _artistImageUrl: string | undefined; -} +}; export type GetArtistsResponse = SubsonicResponse & { artists: { @@ -118,31 +119,33 @@ export type GetArtistResponse = SubsonicResponse & { }; 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" -} + _id: string; + _parent: string; + _title: string; + _album: string; + _artist: string; + _track: string; + _genre: 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[] - } -} + _id: string; + _name: string; + _genre: string; + _year: string; + song: song[]; + }; +}; export function isError( subsonicResponse: SubsonicResponse @@ -329,29 +332,57 @@ export class Navidrome implements MusicService { total: Math.min(MAX_ALBUM_LIST, total), })); }, - album: (id: string): Promise => navidrome - .get(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, - })) - })), + album: (id: string): Promise => + navidrome + .get(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: () => navidrome .get(credentials, "/rest/getGenres") - .then((it) => pipe( - it.genres.genre, - A.map(it => it.__text), - A.sort(ordString) - )), + .then((it) => + pipe( + it.genres.genre, + A.map((it) => it.__text), + A.sort(ordString) + ) + ), + tracks: (albumId: string) => + navidrome + .get(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); diff --git a/src/smapi.ts b/src/smapi.ts index d0826b4..3346ec2 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -11,6 +11,7 @@ import { MusicLibrary, MusicService, slice2, + Track, } from "./music_service"; export const LOGIN_ROUTE = "/login"; @@ -168,7 +169,7 @@ const genre = (genre: string) => ({ itemType: "container", id: `genre:${genre}`, title: genre, -}) +}); const album = (album: AlbumSummary) => ({ 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 = { credentials?: Credentials; }; @@ -280,8 +302,8 @@ function bindSmapiSoapServiceToExpress( index: paging._index, total, }) - ); - case "artist": + ); + case "artist": return await musicLibrary .artist(typeId!) .then((artist) => artist.albums) @@ -289,7 +311,18 @@ function bindSmapiSoapServiceToExpress( .then(([page, total]) => getMetadataResult({ 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, }) ); diff --git a/tests/builders.ts b/tests/builders.ts index 138a587..eb5065f 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -83,6 +83,9 @@ export function anArtist(fields: Partial = {}): 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 { const id = uuid(); return { @@ -90,19 +93,21 @@ export function aTrack(fields: Partial = {}): Track { name: `Track ${id}`, mimeType: `audio/mp3-${id}`, duration: `${randomInt(500)}`, + number: `${randomInt(100)}`, + genre: randomGenre(), + artist: anArtist(), + album: anAlbum(), ...fields } } export function anAlbum(fields: Partial = {}): Album { - const genres = ["Metal", "Pop", "Rock", "Hip-Hop"]; const id = uuid(); return { id, name: `Album ${id}`, - genre: genres[randomInt(genres.length)], + genre: randomGenre(), year: `19${randomInt(99)}`, - tracks: [aTrack(), aTrack(), aTrack()], ...fields, }; } diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index c9e70d2..8fab0d5 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -6,7 +6,7 @@ import { albumToAlbumSummary, } from "../src/music_service"; import { v4 as uuid } from "uuid"; -import { anArtist, anAlbum } from "./builders"; +import { anArtist, anAlbum, aTrack } from "./builders"; describe("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", () => { const artist1_album1 = anAlbum({ genre: "Pop" }); const artist1_album2 = anAlbum({ genre: "Rock" }); @@ -332,8 +360,20 @@ describe("InMemoryMusicService", () => { }); describe("genres", () => { - const artist1 = anArtist({ albums: [anAlbum({ genre: "Pop" }), anAlbum({ genre: "Rock" }), anAlbum({ genre: "Pop" })] }); - const artist2 = anArtist({ albums: [anAlbum({ genre: "Hip-Hop" }), anAlbum({ genre: "Rap" }), anAlbum({ genre: "Pop" })] }); + const artist1 = anArtist({ + 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(() => { service.hasArtists(artist1, artist2); @@ -341,13 +381,11 @@ describe("InMemoryMusicService", () => { describe("fetching all in one page", () => { it("should provide an array of artists", async () => { - expect( - await musicLibrary.genres() - ).toEqual([ + expect(await musicLibrary.genres()).toEqual([ "Hip-Hop", "Pop", "Rap", - "Rock" + "Rock", ]); }); }); diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index e67d2d9..156c1a1 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -18,6 +18,7 @@ import { artistToArtistSummary, albumToAlbumSummary, Album, + Track, } from "../src/music_service"; type P = (t: T) => boolean; @@ -29,6 +30,7 @@ const albumWithGenre = (genre: string): P<[Artist, Album]> => ([_, album]) => export class InMemoryMusicService implements MusicService { users: Record = {}; artists: Artist[] = []; + tracks: Track[] = []; generateToken({ username, @@ -101,6 +103,7 @@ export class InMemoryMusicService implements MusicService { 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; } + hasTracks(...newTracks: Track[]) { + this.tracks = [...this.tracks, ...newTracks]; + return this; + } + clear() { this.users = {}; this.artists = []; + this.tracks = []; return this; } } diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index 839aac0..2bfde46 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -16,7 +16,9 @@ import { range, asArtistAlbumPairs, Track, - AlbumSummary + AlbumSummary, + artistToArtistSummary, + NO_IMAGES } from "../src/music_service"; 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" artistId="${artist.id}" songCount="19" - isVideo="false">${tracks.map(track => songXml(artist, album, track))}`; + isVideo="false">${tracks.map(track => songXml(track))}`; -const songXml = (artist: Artist, album: AlbumSummary, track: Track) => ` ` ``; const albumListXml = ( @@ -133,8 +137,8 @@ const genresXml = ( `; -const getAlbumXml = (artist: Artist, album: Album) => ` - ${albumXml(artist, album, album.tracks)} +const getAlbumXml = (artist: Artist, album: Album, tracks: Track[]) => ` + ${albumXml(artist, album, tracks)} ` const PING_OK = ``; @@ -624,22 +628,24 @@ describe("Navidrome", () => { describe("getting an album", () => { describe("when it exists", () => { - const album = anAlbum({ tracks: [ - aTrack(), - aTrack(), - aTrack(), - aTrack(), - ] }); + const album = anAlbum(); const artist = anArtist({ albums: [album] }) + const tracks = [ + aTrack({ artist, album }), + aTrack({ artist, album }), + aTrack({ artist, album }), + aTrack({ artist, album }), + ] + beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve( 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, + }, + }); + }); + }); + }); + }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 1c824be..f143aad 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -14,6 +14,7 @@ import { someCredentials, anArtist, anAlbum, + aTrack, } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import supersoap from "./supersoap"; @@ -470,7 +471,7 @@ describe("api", () => { id: `album:${it.id}`, title: it.name, })), - index: 0, + index: 2, 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, + }) + ); + }); + }); + }); + }); }); }); });