From cc95beb4f2d6471b6c1b2a239f643d32dbfea94b Mon Sep 17 00:00:00 2001 From: Simon J Date: Fri, 8 Oct 2021 00:08:32 +1100 Subject: [PATCH] Ability to see TopRated/starred albums (#63) --- src/i8n.ts | 18 +++++------ src/music_service.ts | 2 +- src/smapi.ts | 40 ++++++++++++------------ src/subsonic.ts | 66 +++++++++++++++++++++++++++------------- tests/smapi.test.ts | 66 ++++++++++++++++++++++++++++++++++++++-- tests/subsonic.test.ts | 69 +++++++++++++++++++++++++++++++++++------- 6 files changed, 196 insertions(+), 65 deletions(-) diff --git a/src/i8n.ts b/src/i8n.ts index 522bd97..4a73f23 100644 --- a/src/i8n.ts +++ b/src/i8n.ts @@ -81,10 +81,10 @@ const translations: Record> = { loginFailed: "Login failed!", noSonosDevices: "No sonos devices", favourites: "Favourites", - STAR: "Star track", - UNSTAR: "Un-star track", - STAR_SUCCESS: "Track starred successfully", - UNSTAR_SUCCESS: "Track un-starred successfully", + STAR: "Star", + UNSTAR: "Un-star", + STAR_SUCCESS: "Track starred", + UNSTAR_SUCCESS: "Track un-starred", LOVE: "Love", LOVE_SUCCESS: "Track loved" }, @@ -122,11 +122,11 @@ const translations: Record> = { loginFailed: "Inloggen mislukt!", noSonosDevices: "Geen Sonos-apparaten", favourites: "Favorieten", - STAR: "Ster spoor", - UNSTAR: "Track zonder ster", - STAR_SUCCESS: "Track succesvol gemarkeerd", - UNSTAR_SUCCESS: "Succes zonder ster bijhouden", - LOVE: "Liefde ", + STAR: "Ster ", + UNSTAR: "Een ster", + STAR_SUCCESS: "Nummer met ster", + UNSTAR_SUCCESS: "Track zonder ster", + LOVE: "Liefde", LOVE_SUCCESS: "Volg geliefd" }, }; diff --git a/src/music_service.ts b/src/music_service.ts index 9054714..792fc88 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -107,7 +107,7 @@ export const asResult = ([results, total]: [T[], number]) => ({ export type ArtistQuery = Paging; -export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'random' | 'recent' | 'frequent' | 'newest' | 'starred'; +export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'random' | 'recentlyPlayed' | 'mostPlayed' | 'recentlyAdded' | 'favourited' | 'starred'; export type AlbumQuery = Paging & { type: AlbumQueryType; diff --git a/src/smapi.ts b/src/smapi.ts index 8fc9c06..0113dc5 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -81,10 +81,11 @@ export type GetDeviceAuthTokenResult = { }; }; -export const ratingAsInt = (rating: Rating): number => rating.stars * 10 + (rating.love ? 1 : 0) + 100; +export const ratingAsInt = (rating: Rating): number => + rating.stars * 10 + (rating.love ? 1 : 0) + 100; export const ratingFromInt = (value: number): Rating => { const x = value - 100; - return { love: (x % 10 == 1), stars: Math.floor(x / 10) } + return { love: x % 10 == 1, stars: Math.floor(x / 10) }; }; export type MediaCollection = { @@ -309,7 +310,7 @@ export const track = (bonobUrl: URLBuilder, track: Track) => ({ }, dynamic: { property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }], - } + }, }); export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({ @@ -426,10 +427,7 @@ function bindSmapiSoapServiceToExpress( .then(splitId(id)) .then(async ({ musicLibrary, accessToken, typeId }) => musicLibrary.track(typeId!).then((it) => ({ - getMediaMetadataResult: track( - urlWithToken(accessToken), - it - ), + getMediaMetadataResult: track(urlWithToken(accessToken), it), })) ), search: async ( @@ -516,10 +514,7 @@ function bindSmapiSoapServiceToExpress( case "track": return musicLibrary.track(typeId).then((it) => ({ getExtendedMetadataResult: { - mediaMetadata: track( - urlWithToken(accessToken), - it - ), + mediaMetadata: track(urlWithToken(accessToken), it), }, })); case "album": @@ -606,12 +601,12 @@ function bindSmapiSoapServiceToExpress( albumArtURI: iconArtURI(bonobUrl, "heart").href(), itemType: "albumList", }, - // { - // id: "topRatedAlbums", - // title: lang("topRated"), - // albumArtURI: iconArtURI(bonobUrl, "star").href(), - // itemType: "albumList", - // }, + { + id: "starredAlbums", + title: lang("topRated"), + albumArtURI: iconArtURI(bonobUrl, "star").href(), + itemType: "albumList", + }, { id: "playlists", title: lang("playlists"), @@ -710,23 +705,28 @@ function bindSmapiSoapServiceToExpress( ...paging, }); case "favouriteAlbums": + return albums({ + type: "favourited", + ...paging, + }); + case "starredAlbums": return albums({ type: "starred", ...paging, }); case "recentlyAdded": return albums({ - type: "newest", + type: "recentlyAdded", ...paging, }); case "recentlyPlayed": return albums({ - type: "recent", + type: "recentlyPlayed", ...paging, }); case "mostPlayed": return albums({ - type: "frequent", + type: "mostPlayed", ...paging, }); case "genres": diff --git a/src/subsonic.ts b/src/subsonic.ts index 3459e34..ec28ce1 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -20,6 +20,7 @@ import { Track, CoverArt, Rating, + AlbumQueryType, } from "./music_service"; import sharp from "sharp"; import _ from "underscore"; @@ -204,6 +205,7 @@ export type GetSongResponse = { export type GetStarredResponse = { starred2: { song: song[]; + album: album[]; }; }; @@ -261,11 +263,14 @@ export const asTrack = (album: Album, song: song): Track => ({ }, rating: { love: song.starred != undefined, - stars: (song.userRating && song.userRating <= 5 && song.userRating >= 0 ? song.userRating : 0), + stars: + song.userRating && song.userRating <= 5 && song.userRating >= 0 + ? song.userRating + : 0, }, }); -const asAlbum = (album: album) => ({ +const asAlbum = (album: album): Album => ({ id: album.id, name: album.name, year: album.year, @@ -350,6 +355,18 @@ export const axiosImageFetcher = (url: string): Promise => })) .catch(() => undefined); +const AlbumQueryTypeToSubsonicType: Record = { + alphabeticalByArtist: "alphabeticalByArtist", + alphabeticalByName: "alphabeticalByName", + byGenre: "byGenre", + random: "random", + recentlyPlayed: "recent", + mostPlayed: "frequent", + recentlyAdded: "newest", + favourited: "starred", + starred: "highest", +}; + export class Subsonic implements MusicService { url: string; encryption: Encryption; @@ -536,6 +553,31 @@ export class Subsonic implements MusicService { songs: it.searchResult3.song || [], })); + getAlbumList2 = (credentials: Credentials, q: AlbumQuery) => + Promise.all([ + this.getArtists(credentials).then((it) => + _.inject(it, (total, artist) => total + artist.albumCount, 0) + ), + this.getJSON(credentials, "/rest/getAlbumList2", { + type: AlbumQueryTypeToSubsonicType[q.type], + ...(q.genre ? { genre: b64Decode(q.genre) } : {}), + size: 500, + offset: q._index, + }) + .then((response) => response.albumList2.album || []) + .then(this.toAlbumSummary), + ]).then(([total, albums]) => ({ + results: albums.slice(0, q._count), + total: albums.length == 500 ? total : q._index + albums.length, + })); + + // getStarred2 = (credentials: Credentials): Promise<{ albums: Album[] }> => + // this.getJSON(credentials, "/rest/getStarred2") + // .then((it) => it.starred2) + // .then((it) => ({ + // albums: it.album.map(asAlbum), + // })); + async login(token: string) { const subsonic = this; const credentials: Credentials = this.parseToken(token); @@ -552,25 +594,7 @@ export class Subsonic implements MusicService { artist: async (id: string): Promise => subsonic.getArtistWithInfo(credentials, id), albums: async (q: AlbumQuery): Promise> => - Promise.all([ - subsonic - .getArtists(credentials) - .then((it) => - _.inject(it, (total, artist) => total + artist.albumCount, 0) - ), - subsonic - .getJSON(credentials, "/rest/getAlbumList2", { - type: q.type, - ...(q.genre ? { genre: b64Decode(q.genre) } : {}), - size: 500, - offset: q._index, - }) - .then((response) => response.albumList2.album || []) - .then(subsonic.toAlbumSummary), - ]).then(([total, albums]) => ({ - results: albums.slice(0, q._count), - total: albums.length == 500 ? total : q._index + albums.length, - })), + subsonic.getAlbumList2(credentials, q), album: (id: string): Promise => subsonic.getAlbum(credentials, id), genres: () => subsonic diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index a06b9c4..e8ccc85 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -977,6 +977,12 @@ describe("api", () => { albumArtURI: iconArtURI(bonobUrl, "heart").href(), itemType: "albumList", }, + { + id: "starredAlbums", + title: "Top Rated", + albumArtURI: iconArtURI(bonobUrl, "star").href(), + itemType: "albumList", + }, { id: "playlists", title: "Playlists", @@ -1064,6 +1070,12 @@ describe("api", () => { albumArtURI: iconArtURI(bonobUrl, "heart").href(), itemType: "albumList", }, + { + id: "starredAlbums", + title: "Best beoordeeld", + albumArtURI: iconArtURI(bonobUrl, "star").href(), + itemType: "albumList", + }, { id: "playlists", title: "Afspeellijsten", @@ -1705,6 +1717,54 @@ describe("api", () => { }) ); + expect(musicLibrary.albums).toHaveBeenCalledWith({ + type: "favourited", + _index: paging.index, + _count: paging.count, + }); + }); + }); + + describe("asking for starred albums", () => { + const albums = [rock2, rock1, pop2]; + + beforeEach(() => { + musicLibrary.albums.mockResolvedValue({ + results: albums, + total: allAlbums.length, + }); + }); + + it("should return some", async () => { + const paging = { + index: 0, + count: 100, + }; + + const result = await ws.getMetadataAsync({ + id: "starredAlbums", + ...paging, + }); + + expect(result[0]).toEqual( + getMetadataResult({ + mediaCollection: albums.map((it) => ({ + itemType: "album", + id: `album:${it.id}`, + title: it.name, + albumArtURI: defaultAlbumArtURI( + bonobUrlWithAccessToken, + it + ).href(), + canPlay: true, + artistId: `artist:${it.artistId}`, + artist: it.artistName, + })), + index: 0, + total: 6, + }) + ); + expect(musicLibrary.albums).toHaveBeenCalledWith({ type: "starred", _index: paging.index, @@ -1754,7 +1814,7 @@ describe("api", () => { ); expect(musicLibrary.albums).toHaveBeenCalledWith({ - type: "recent", + type: "recentlyPlayed", _index: paging.index, _count: paging.count, }); @@ -1802,7 +1862,7 @@ describe("api", () => { ); expect(musicLibrary.albums).toHaveBeenCalledWith({ - type: "frequent", + type: "mostPlayed", _index: paging.index, _count: paging.count, }); @@ -1850,7 +1910,7 @@ describe("api", () => { ); expect(musicLibrary.albums).toHaveBeenCalledWith({ - type: "newest", + type: "recentlyAdded", _index: paging.index, _count: paging.count, }); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index add0a81..2788667 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -266,7 +266,7 @@ const maybeIdFromCoverArtId = (coverArt: string | undefined) => coverArt ? splitCoverArtId(coverArt)[1] : ""; const asAlbumJson = ( - artist: Artist, + artist: { id: string | undefined, name: string | undefined }, album: AlbumSummary, tracks: Track[] = [] ) => ({ @@ -353,9 +353,9 @@ const getAlbumJson = (artist: Artist, album: Album, tracks: Track[]) => const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); -// const getStarredJson = ({ songIds }: { songIds: string[] }) => subsonicOK({starred2: { -// album: [], -// song: songIds.map((id) => ({ id })), +// const getStarredJson = ({ albums }: { albums: Album[] }) => subsonicOK({starred2: { +// album: albums.map(it => asAlbumJson({ id: it.artistId, name: it.artistName }, it, [])), +// song: [], // }}) const subsonicOK = (body: any = {}) => ({ @@ -1389,11 +1389,13 @@ describe("Subsonic", () => { describe("getting albums", () => { describe("filtering", () => { - const album1 = anAlbum({ genre: asGenre("Pop") }); - const album2 = anAlbum({ genre: asGenre("Rock") }); - const album3 = anAlbum({ genre: asGenre("Pop") }); + const album1 = anAlbum({ id: "album1", genre: asGenre("Pop") }); + const album2 = anAlbum({ id: "album2", genre: asGenre("Rock") }); + const album3 = anAlbum({ id: "album3", genre: asGenre("Pop") }); + const album4 = anAlbum({ id: "album4", genre: asGenre("Pop") }); + const album5 = anAlbum({ id: "album5", genre: asGenre("Pop") }); - const artist = anArtist({ albums: [album1, album2, album3] }); + const artist = anArtist({ albums: [album1, album2, album3, album4, album5] }); describe("by genre", () => { beforeEach(() => { @@ -1472,7 +1474,7 @@ describe("Subsonic", () => { }); it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "newest" }; + const q: AlbumQuery = { _index: 0, _count: 100, type: "recentlyAdded" }; const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) @@ -1522,7 +1524,7 @@ describe("Subsonic", () => { }); it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "recent" }; + const q: AlbumQuery = { _index: 0, _count: 100, type: "recentlyPlayed" }; const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) @@ -1567,7 +1569,7 @@ describe("Subsonic", () => { }); it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "frequent" }; + const q: AlbumQuery = { _index: 0, _count: 100, type: "mostPlayed" }; const result = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) @@ -1595,6 +1597,51 @@ describe("Subsonic", () => { }); }); }); + + describe("by starred", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce( + () => + // album1 never played + Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) + // album3 never played + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { _index: 0, _count: 100, type: "starred" }; + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.albums(q)); + + expect(result).toEqual({ + results: [album2].map(albumToAlbumSummary), + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "highest", + size: 500, + offset: 0, + }), + headers, + }); + }); + }); }); describe("when the artist has only 1 album", () => {