diff --git a/package.json b/package.json index 8ee5b27..9e5e6df 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,7 @@ "typescript": "^4.4.2", "underscore": "^1.13.1", "uuid": "^8.3.2", - "winston": "^3.3.3", - "x2js": "^3.4.2" + "winston": "^3.3.3" }, "devDependencies": { "@types/chai": "^4.2.21", @@ -54,8 +53,8 @@ "scripts": { "clean": "rm -Rf build node_modules", "build": "tsc", - "dev": "BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts", - "devr": "BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts", + "dev": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts", + "devr": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts", "register-dev": "ts-node ./src/register.ts http://$(hostname):4534", "test": "jest", "gitinfo": "git describe --tags > .gitinfo" diff --git a/src/i8n.ts b/src/i8n.ts index 4be7862..522bd97 100644 --- a/src/i8n.ts +++ b/src/i8n.ts @@ -12,7 +12,7 @@ export type KEY = | "playlists" | "genres" | "random" - | "starred" + | "topRated" | "recentlyAdded" | "recentlyPlayed" | "mostPlayed" @@ -37,7 +37,14 @@ export type KEY = | "invalidLinkCode" | "loginSuccessful" | "loginFailed" - | "noSonosDevices"; + | "noSonosDevices" + | "favourites" + | "LOVE" + | "LOVE_SUCCESS" + | "STAR" + | "UNSTAR" + | "STAR_SUCCESS" + | "UNSTAR_SUCCESS"; const translations: Record> = { "en-US": { @@ -48,7 +55,7 @@ const translations: Record> = { playlists: "Playlists", genres: "Genres", random: "Random", - starred: "Starred", + topRated: "Top Rated", recentlyAdded: "Recently added", recentlyPlayed: "Recently played", mostPlayed: "Most played", @@ -73,6 +80,13 @@ const translations: Record> = { loginSuccessful: "Login successful!", 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", + LOVE: "Love", + LOVE_SUCCESS: "Track loved" }, "nl-NL": { AppLinkMessage: "Sonos koppelen aan $BNB_SONOS_SERVICE_NAME", @@ -82,7 +96,7 @@ const translations: Record> = { playlists: "Afspeellijsten", genres: "Genres", random: "Willekeurig", - starred: "Favorieten", + topRated: "Best beoordeeld", recentlyAdded: "Onlangs toegevoegd", recentlyPlayed: "Onlangs afgespeeld", mostPlayed: "Meest afgespeeld", @@ -107,6 +121,13 @@ const translations: Record> = { loginSuccessful: "Inloggen gelukt!", 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 ", + LOVE_SUCCESS: "Volg geliefd" }, }; diff --git a/src/icon.ts b/src/icon.ts index afa7e36..dc9cf22 100644 --- a/src/icon.ts +++ b/src/icon.ts @@ -166,7 +166,7 @@ export type ICON = | "playlists" | "genres" | "random" - | "starred" + | "topRated" | "recentlyAdded" | "recentlyPlayed" | "mostPlayed" @@ -225,7 +225,10 @@ export type ICON = | "skywalker" | "leia" | "r2d2" - | "yoda"; + | "yoda" + | "heart" + | "star" + | "solidStar"; const iconFrom = (name: string) => new SvgIcon( @@ -241,7 +244,7 @@ export const ICONS: Record = { playlists: iconFrom("navidrome-playlists.svg"), genres: iconFrom("Theatre-Mask-111172.svg"), random: iconFrom("navidrome-random.svg"), - starred: iconFrom("navidrome-topRated.svg"), + topRated: iconFrom("navidrome-topRated.svg"), recentlyAdded: iconFrom("navidrome-recentlyAdded.svg"), recentlyPlayed: iconFrom("navidrome-recentlyPlayed.svg"), mostPlayed: iconFrom("navidrome-mostPlayed.svg"), @@ -300,6 +303,9 @@ export const ICONS: Record = { leia: iconFrom("Princess-Leia-68568.svg"), r2d2: iconFrom("R2-D2-39423.svg"), yoda: iconFrom("Yoda-68107.svg"), + heart: iconFrom("Heart-85038.svg"), + star: iconFrom("Star-16101.svg"), + solidStar: iconFrom("Star-43879.svg") }; export const STAR_WARS = [ICONS.c3po, ICONS.chewy, ICONS.darth, ICONS.skywalker, ICONS.leia, ICONS.r2d2, ICONS.yoda]; diff --git a/src/music_service.ts b/src/music_service.ts index 6792384..9054714 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -54,8 +54,8 @@ export type AlbumSummary = { genre: Genre | undefined; coverArt: string | undefined; - artistName: string; - artistId: string; + artistName: string | undefined; + artistId: string | undefined; }; export type Album = AlbumSummary & {}; @@ -65,6 +65,11 @@ export type Genre = { id: string; } +export type Rating = { + love: boolean; + stars: number; +} + export type Track = { id: string; name: string; @@ -75,6 +80,7 @@ export type Track = { coverArt: string | undefined; album: AlbumSummary; artist: ArtistSummary; + rating: Rating; }; export type Paging = { @@ -177,6 +183,7 @@ export interface MusicLibrary { trackId: string; range: string | undefined; }): Promise; + rate(trackId: string, rating: Rating): Promise; coverArt(id: string, size?: number): Promise; nowPlaying(id: string): Promise scrobble(id: string): Promise diff --git a/src/server.ts b/src/server.ts index 081ab81..88872f9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,6 +4,7 @@ import * as Eta from "eta"; import path from "path"; import sharp from "sharp"; import { v4 as uuid } from "uuid"; +import dayjs from "dayjs"; import { PassThrough, Transform, TransformCallback } from "stream"; @@ -17,6 +18,8 @@ import { CREATE_REGISTRATION_ROUTE, REMOVE_REGISTRATION_ROUTE, sonosifyMimeType, + ratingFromInt, + ratingAsInt, } from "./smapi"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { MusicService, isSuccess } from "./music_service"; @@ -107,6 +110,8 @@ function server( const accessTokens = serverOpts.accessTokens(); const clock = serverOpts.clock; + const startUpTime = dayjs(); + const app = express(); const i8n = makeI8N(service.name); @@ -253,6 +258,28 @@ function server( }); app.get(PRESENTATION_MAP_ROUTE, (_, res) => { + const LastModified = startUpTime.format("HH:mm:ss D MMM YYYY"); + + const nowPlayingRatingsMatch = (value: number) => { + const rating = ratingFromInt(value); + const nextLove = { ...rating, love: !rating.love }; + const nextStar = { ...rating, stars: (rating.stars === 5 ? 0 : rating.stars + 1) } + + const loveRatingIcon = bonobUrl.append({pathname: rating.love ? '/love-selected.svg' : '/love-unselected.svg'}).href(); + const starsRatingIcon = bonobUrl.append({pathname: `/star${rating.stars}.svg`}).href(); + + return ` + + + + + + + + + ` + } + res.type("application/xml").send(` @@ -285,6 +312,20 @@ function server( + + ${nowPlayingRatingsMatch(100)} + ${nowPlayingRatingsMatch(101)} + ${nowPlayingRatingsMatch(110)} + ${nowPlayingRatingsMatch(111)} + ${nowPlayingRatingsMatch(120)} + ${nowPlayingRatingsMatch(121)} + ${nowPlayingRatingsMatch(130)} + ${nowPlayingRatingsMatch(131)} + ${nowPlayingRatingsMatch(140)} + ${nowPlayingRatingsMatch(141)} + ${nowPlayingRatingsMatch(150)} + ${nowPlayingRatingsMatch(151)} + `); }); diff --git a/src/smapi.ts b/src/smapi.ts index 087e367..8fc9c06 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -14,6 +14,7 @@ import { Genre, MusicService, Playlist, + Rating, slice2, Track, } from "./music_service"; @@ -80,6 +81,12 @@ export type GetDeviceAuthTokenResult = { }; }; +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) } +}; + export type MediaCollection = { id: string; itemType: "collection"; @@ -300,6 +307,9 @@ export const track = (bonobUrl: URLBuilder, track: Track) => ({ genreId: track.album.genre?.id, trackNumber: track.number, }, + dynamic: { + property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }], + } }); export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({ @@ -403,7 +413,7 @@ function bindSmapiSoapServiceToExpress( getMediaURIResult: bonobUrl .append({ pathname: `/stream/${type}/${typeId}`, - searchParams: { "bat": accessToken } + searchParams: { bat: accessToken }, }) .href(), })), @@ -416,7 +426,10 @@ 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 ( @@ -503,9 +516,10 @@ function bindSmapiSoapServiceToExpress( case "track": return musicLibrary.track(typeId).then((it) => ({ getExtendedMetadataResult: { - mediaMetadata: { - ...track(urlWithToken(accessToken), it), - }, + mediaMetadata: track( + urlWithToken(accessToken), + it + ), }, })); case "album": @@ -580,6 +594,24 @@ function bindSmapiSoapServiceToExpress( albumArtURI: iconArtURI(bonobUrl, "albums").href(), itemType: "albumList", }, + { + id: "randomAlbums", + title: lang("random"), + albumArtURI: iconArtURI(bonobUrl, "random").href(), + itemType: "albumList", + }, + { + id: "favouriteAlbums", + title: lang("favourites"), + albumArtURI: iconArtURI(bonobUrl, "heart").href(), + itemType: "albumList", + }, + // { + // id: "topRatedAlbums", + // title: lang("topRated"), + // albumArtURI: iconArtURI(bonobUrl, "star").href(), + // itemType: "albumList", + // }, { id: "playlists", title: lang("playlists"), @@ -597,18 +629,6 @@ function bindSmapiSoapServiceToExpress( albumArtURI: iconArtURI(bonobUrl, "genres").href(), itemType: "container", }, - { - id: "randomAlbums", - title: lang("random"), - albumArtURI: iconArtURI(bonobUrl, "random").href(), - itemType: "albumList", - }, - { - id: "starredAlbums", - title: lang("starred"), - albumArtURI: iconArtURI(bonobUrl, "starred").href(), - itemType: "albumList", - }, { id: "recentlyAdded", title: lang("recentlyAdded"), @@ -689,7 +709,7 @@ function bindSmapiSoapServiceToExpress( type: "random", ...paging, }); - case "starredAlbums": + case "favouriteAlbums": return albums({ type: "starred", ...paging, @@ -872,6 +892,18 @@ function bindSmapiSoapServiceToExpress( } }) .then((_) => ({ removeFromContainerResult: { updateId: "" } })), + rateItem: async ( + { id, rating }: { id: string; rating: number }, + _, + soapyHeaders: SoapyHeaders + ) => + auth(musicService, accessTokens, soapyHeaders?.credentials) + .then(splitId(id)) + .then(({ musicLibrary, typeId }) => + musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating))) + ) + .then((_) => ({ rateItemResult: { shouldSkip: false } })), + setPlayedSeconds: async ( { id, seconds }: { id: string; seconds: string }, _, diff --git a/src/sonos.ts b/src/sonos.ts index f01d56b..62db494 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -24,25 +24,25 @@ export const SONOS_LANG: LANG[] = [ "zh-CN", ]; -export const PRESENTATION_AND_STRINGS_VERSION = "21"; +export const PRESENTATION_AND_STRINGS_VERSION = + process.env["BNB_DEBUG"] === "true" + ? `${Math.round(new Date().getTime() / 1000)}` + : "23"; -// NOTE: manifest requires https for the URL, -// otherwise you will get an error trying to register +// NOTE: manifest requires https for the URL, otherwise you will get an error trying to register export type Capability = | "search" - | "trFavorites" - | "alFavorites" - | "ucPlaylists" - | "extendedMD" + | "trFavorites" // Favorites: Adding/Removing Tracks (deprecated) + | "alFavorites" // Favorites: Adding/Removing Albums (deprecated) + | "ucPlaylists" // User Content Playlists + | "extendedMD" // Extended Metadata (More Menu, Info & Options) | "contextHeaders" | "authorizationHeader" - | "logging" + | "logging" // Playback duration logging at track end (deprecated) | "manifest"; export const BONOB_CAPABILITIES: Capability[] = [ "search", - // "trFavorites", - // "alFavorites", "ucPlaylists", "extendedMD", "logging", @@ -247,9 +247,7 @@ export type Discovery = { seedHost?: string; }; -export default ( - sonosDiscovery: Discovery = { enabled: true } -): Sonos => +export default (sonosDiscovery: Discovery = { enabled: true }): Sonos => sonosDiscovery.enabled ? autoDiscoverySonos(sonosDiscovery.seedHost) : SONOS_DISABLED; diff --git a/src/subsonic.ts b/src/subsonic.ts index 645581c..3459e34 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -19,8 +19,8 @@ import { Genre, Track, CoverArt, + Rating, } from "./music_service"; -import X2JS from "x2js"; import sharp from "sharp"; import _ from "underscore"; import fse from "fs-extra"; @@ -60,36 +60,36 @@ export const isDodgyImage = (url: string) => url.endsWith(DODGY_IMAGE_NAME); export const validate = (url: string | undefined) => url && !isDodgyImage(url) ? url : undefined; -export type SubconicEnvelope = { +export type SubsonicEnvelope = { "subsonic-response": SubsonicResponse; }; export type SubsonicResponse = { - _status: string; + status: string; }; export type album = { - _id: string; - _name: string; - _genre: string | undefined; - _year: string | undefined; - _coverArt: string | undefined; - _artist: string; - _artistId: string; + id: string; + name: string; + artist: string | undefined; + artistId: string | undefined; + coverArt: string | undefined; + genre: string | undefined; + year: string | undefined; }; export type artistSummary = { - _id: string; - _name: string; - _albumCount: string; - _artistImageUrl: string | undefined; + id: string; + name: string; + albumCount: number; + artistImageUrl: string | undefined; }; export type GetArtistsResponse = SubsonicResponse & { artists: { index: { artist: artistSummary[]; - _name: string; + name: string; }[]; }; }; @@ -101,9 +101,9 @@ export type GetAlbumListResponse = SubsonicResponse & { }; export type genre = { - _songCount: string; - _albumCount: string; - __text: string; + songCount: number; + albumCount: number; + value: string; }; export type GetGenresResponse = SubsonicResponse & { @@ -114,8 +114,8 @@ export type GetGenresResponse = SubsonicResponse & { export type SubsonicError = SubsonicResponse & { error: { - _code: string; - _message: string; + code: string; + message: string; }; }; @@ -145,22 +145,25 @@ export type GetArtistResponse = SubsonicResponse & { }; export type song = { - _id: string; - _parent: string; - _title: string; - _album: string; - _artist: string; - _track: string | undefined; - _genre: string; - _coverArt: string | undefined; - _created: "2004-11-08T23:36:11"; - _duration: string | undefined; - _bitRate: "128"; - _suffix: "mp3"; - _contentType: string; - _albumId: string; - _artistId: string; - _type: "music"; + id: string; + parent: string | undefined; + title: string; + album: string | undefined; + artist: string | undefined; + track: number | undefined; + year: string | undefined; + genre: string | undefined; + coverArt: string | undefined; + created: string | undefined; + duration: number | undefined; + bitRate: number | undefined; + suffix: string | undefined; + contentType: string | undefined; + albumId: string | undefined; + artistId: string | undefined; + type: string | undefined; + userRating: number | undefined; + starred: string | undefined; }; export type GetAlbumResponse = { @@ -170,31 +173,15 @@ export type GetAlbumResponse = { }; export type playlist = { - _id: string; - _name: string; -}; - -export type entry = { - _id: string; - _parent: string; - _title: string; - _album: string; - _artist: string; - _track: string; - _year: string; - _genre: string; - _coverArt: string; - _contentType: string; - _duration: string; - _albumId: string; - _artistId: string; + id: string; + name: string; }; export type GetPlaylistResponse = { playlist: { - _id: string; - _name: string; - entry: entry[]; + id: string; + name: string; + entry: song[]; }; }; @@ -214,6 +201,12 @@ export type GetSongResponse = { song: song; }; +export type GetStarredResponse = { + starred2: { + song: song[]; + }; +}; + export type Search3Response = SubsonicResponse & { searchResult3: { artist: artistSummary[]; @@ -253,29 +246,33 @@ export const MAX_ALBUM_LIST = 500; const maybeAsCoverArt = (coverArt: string | undefined) => coverArt ? `coverArt:${coverArt}` : undefined; -const asTrack = (album: Album, song: song) => ({ - id: song._id, - name: song._title, - mimeType: song._contentType, - duration: parseInt(song._duration || "0"), - number: parseInt(song._track || "0"), - genre: maybeAsGenre(song._genre), - coverArt: maybeAsCoverArt(song._coverArt), +export const asTrack = (album: Album, song: song): Track => ({ + id: song.id, + name: song.title, + mimeType: song.contentType!, + duration: song.duration || 0, + number: song.track || 0, + genre: maybeAsGenre(song.genre), + coverArt: maybeAsCoverArt(song.coverArt), album, artist: { - id: song._artistId, - name: song._artist, + id: `${song.artistId!}`, + name: song.artist!, + }, + rating: { + love: song.starred != undefined, + stars: (song.userRating && song.userRating <= 5 && song.userRating >= 0 ? song.userRating : 0), }, }); const asAlbum = (album: album) => ({ - id: album._id, - name: album._name, - year: album._year, - genre: maybeAsGenre(album._genre), - artistId: album._artistId, - artistName: album._artist, - coverArt: maybeAsCoverArt(album._coverArt), + id: album.id, + name: album.name, + year: album.year, + genre: maybeAsGenre(album.genre), + artistId: album.artistId, + artistName: album.artist, + coverArt: maybeAsCoverArt(album.coverArt), }); export const asGenre = (genreName: string) => ({ @@ -318,7 +315,7 @@ export type ImageFetcher = (url: string) => Promise; export const cachingImageFetcher = (cacheDir: string, delegate: ImageFetcher) => - (url: string): Promise => { + async (url: string): Promise => { const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); return fse .readFile(filename) @@ -402,31 +399,11 @@ export class Subsonic implements MusicService { path: string, q: {} = {} ): Promise => - this.get({ username, password }, path, q) - .then( - (response) => - new X2JS({ - arrayAccessFormPaths: [ - "subsonic-response.album.song", - "subsonic-response.albumList2.album", - "subsonic-response.artist.album", - "subsonic-response.artists.index", - "subsonic-response.artists.index.artist", - "subsonic-response.artistInfo2.similarArtist", - "subsonic-response.genres.genre", - "subsonic-response.playlist.entry", - "subsonic-response.playlists.playlist", - "subsonic-response.searchResult3.album", - "subsonic-response.searchResult3.artist", - "subsonic-response.searchResult3.song", - "subsonic-response.similarSongs2.song", - "subsonic-response.topSongs.song", - ], - }).xml2js(response.data) as SubconicEnvelope - ) + this.get({ username, password }, path, { f: "json", ...q }) + .then((response) => response.data as SubsonicEnvelope) .then((json) => json["subsonic-response"]) .then((json) => { - if (isError(json)) throw `Subsonic error:${json.error._message}`; + if (isError(json)) throw `Subsonic error:${json.error.message}`; else return json as unknown as T; }); @@ -451,9 +428,9 @@ export class Subsonic implements MusicService { .then((it) => (it.artists.index || []).flatMap((it) => it.artist || [])) .then((artists) => artists.map((artist) => ({ - id: artist._id, - name: artist._name, - albumCount: Number.parseInt(artist._albumCount), + id: `${artist.id}`, + name: artist.name, + albumCount: artist.albumCount, })) ); @@ -469,9 +446,9 @@ export class Subsonic implements MusicService { large: validate(it.artistInfo2.largeImageUrl), }, similarArtist: (it.artistInfo2.similarArtist || []).map((artist) => ({ - id: artist._id, - name: artist._name, - inLibrary: artist._id != "-1", + id: `${artist.id}`, + name: artist.name, + inLibrary: artist.id != "-1", })), })); @@ -479,13 +456,13 @@ export class Subsonic implements MusicService { this.getJSON(credentials, "/rest/getAlbum", { id }) .then((it) => it.album) .then((album) => ({ - id: album._id, - name: album._name, - year: album._year, - genre: maybeAsGenre(album._genre), - artistId: album._artistId, - artistName: album._artist, - coverArt: maybeAsCoverArt(album._coverArt), + id: album.id, + name: album.name, + year: album.year, + genre: maybeAsGenre(album.genre), + artistId: album.artistId, + artistName: album.artist, + coverArt: maybeAsCoverArt(album.coverArt), })); getArtist = ( @@ -497,8 +474,8 @@ export class Subsonic implements MusicService { }) .then((it) => it.artist) .then((it) => ({ - id: it._id, - name: it._name, + id: it.id, + name: it.name, albums: this.toAlbumSummary(it.album || []), })); @@ -526,20 +503,25 @@ export class Subsonic implements MusicService { }) .then((it) => it.song) .then((song) => - this.getAlbum(credentials, song._albumId).then((album) => + this.getAlbum(credentials, song.albumId!).then((album) => asTrack(album, song) ) ); + getStarred = (credentials: Credentials) => + this.getJSON(credentials, "/rest/getStarred2").then( + (it) => new Set(it.starred2.song.map((it) => it.id)) + ); + toAlbumSummary = (albumList: album[]): AlbumSummary[] => albumList.map((album) => ({ - id: album._id, - name: album._name, - year: album._year, - genre: maybeAsGenre(album._genre), - artistId: album._artistId, - artistName: album._artist, - coverArt: maybeAsCoverArt(album._coverArt), + id: album.id, + name: album.name, + year: album.year, + genre: maybeAsGenre(album.genre), + artistId: album.artistId, + artistName: album.artist, + coverArt: maybeAsCoverArt(album.coverArt), })); search3 = (credentials: Credentials, q: any) => @@ -596,8 +578,8 @@ export class Subsonic implements MusicService { .then((it) => pipe( it.genres.genre || [], - A.filter((it) => Number.parseInt(it._albumCount) > 0), - A.map((it) => it.__text), + A.filter((it) => it.albumCount > 0), + A.map((it) => it.value), A.sort(ordString), A.map((it) => ({ id: b64Encode(it), name: it })) ) @@ -612,6 +594,40 @@ export class Subsonic implements MusicService { (album.song || []).map((song) => asTrack(asAlbum(album), song)) ), track: (trackId: string) => subsonic.getTrack(credentials, trackId), + rate: (trackId: string, rating: Rating) => + Promise.resolve(true) + .then(() => { + if (rating.stars >= 0 && rating.stars <= 5) { + return subsonic.getTrack(credentials, trackId); + } else { + throw `Invalid rating.stars value of ${rating.stars}`; + } + }) + .then((track) => { + const thingsToUpdate = []; + if (track.rating.love != rating.love) { + thingsToUpdate.push( + subsonic.getJSON( + credentials, + `/rest/${rating.love ? "star" : "unstar"}`, + { + id: trackId, + } + ) + ); + } + if (track.rating.stars != rating.stars) { + thingsToUpdate.push( + subsonic.getJSON(credentials, `/rest/setRating`, { + id: trackId, + rating: rating.stars, + }) + ); + } + return Promise.all(thingsToUpdate); + }) + .then(() => true) + .catch(() => false), stream: async ({ trackId, range, @@ -713,7 +729,7 @@ export class Subsonic implements MusicService { }, scrobble: async (id: string) => subsonic - .get(credentials, `/rest/scrobble`, { + .getJSON(credentials, `/rest/scrobble`, { id, submission: true, }) @@ -721,7 +737,7 @@ export class Subsonic implements MusicService { .catch(() => false), nowPlaying: async (id: string) => subsonic - .get(credentials, `/rest/scrobble`, { + .getJSON(credentials, `/rest/scrobble`, { id, submission: false, }) @@ -732,8 +748,8 @@ export class Subsonic implements MusicService { .search3(credentials, { query, artistCount: 20 }) .then(({ artists }) => artists.map((artist) => ({ - id: artist._id, - name: artist._name, + id: artist.id, + name: artist.name, })) ), searchAlbums: async (query: string) => @@ -745,7 +761,7 @@ export class Subsonic implements MusicService { .search3(credentials, { query, songCount: 20 }) .then(({ songs }) => Promise.all( - songs.map((it) => subsonic.getTrack(credentials, it._id)) + songs.map((it) => subsonic.getTrack(credentials, it.id)) ) ), playlists: async () => @@ -753,7 +769,7 @@ export class Subsonic implements MusicService { .getJSON(credentials, "/rest/getPlaylists") .then((it) => it.playlists.playlist || []) .then((playlists) => - playlists.map((it) => ({ id: it._id, name: it._name })) + playlists.map((it) => ({ id: it.id, name: it.name })) ), playlist: async (id: string) => subsonic @@ -764,29 +780,22 @@ export class Subsonic implements MusicService { .then((playlist) => { let trackNumber = 1; return { - id: playlist._id, - name: playlist._name, + id: playlist.id, + name: playlist.name, entries: (playlist.entry || []).map((entry) => ({ - id: entry._id, - name: entry._title, - mimeType: entry._contentType, - duration: parseInt(entry._duration || "0"), + ...asTrack( + { + id: entry.albumId!, + name: entry.album!, + year: entry.year, + genre: maybeAsGenre(entry.genre), + artistName: entry.artist, + artistId: entry.artistId, + coverArt: maybeAsCoverArt(entry.coverArt), + }, + entry + ), number: trackNumber++, - genre: maybeAsGenre(entry._genre), - coverArt: maybeAsCoverArt(entry._coverArt), - album: { - id: entry._albumId, - name: entry._album, - year: entry._year, - genre: maybeAsGenre(entry._genre), - artistName: entry._artist, - artistId: entry._artistId, - coverArt: maybeAsCoverArt(entry._coverArt), - }, - artist: { - id: entry._artistId, - name: entry._artist, - }, })), }; }), @@ -796,7 +805,7 @@ export class Subsonic implements MusicService { name, }) .then((it) => it.playlist) - .then((it) => ({ id: it._id, name: it._name })), + .then((it) => ({ id: it.id, name: it.name })), deletePlaylist: async (id: string) => subsonic .getJSON(credentials, "/rest/deletePlaylist", { @@ -829,7 +838,7 @@ export class Subsonic implements MusicService { Promise.all( songs.map((song) => subsonic - .getAlbum(credentials, song._albumId) + .getAlbum(credentials, song.albumId!) .then((album) => asTrack(album, song)) ) ) @@ -846,7 +855,7 @@ export class Subsonic implements MusicService { Promise.all( songs.map((song) => subsonic - .getAlbum(credentials, song._albumId) + .getAlbum(credentials, song.albumId!) .then((album) => asTrack(album, song)) ) ) diff --git a/tests/builders.ts b/tests/builders.ts index 608dcc8..7d3b6de 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -151,6 +151,7 @@ export function aTrack(fields: Partial = {}): Track { const id = uuid(); const artist = anArtist(); const genre = fields.genre || randomGenre(); + const rating = { love: false, stars: Math.floor(Math.random() * 5) }; return { id, name: `Track ${id}`, @@ -163,9 +164,10 @@ export function aTrack(fields: Partial = {}): Track { anAlbum({ artistId: artist.id, artistName: artist.name, genre }) ), coverArt: `coverArt:${uuid()}`, + rating, ...fields, }; -} +}; export function anAlbum(fields: Partial = {}): Album { const id = uuid(); diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index f56fba5..496a6a5 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -175,8 +175,8 @@ describe("InMemoryMusicService", () => { describe("fetching tracks for an album", () => { it("should return only tracks on that album", async () => { expect(await musicLibrary.tracks(artist1Album1.id)).toEqual([ - track1, - track2, + { ...track1, rating: { love: false, stars: 0 } }, + { ...track2, rating: { love: false, stars: 0 } }, ]); }); }); @@ -192,7 +192,7 @@ describe("InMemoryMusicService", () => { describe("fetching a single track", () => { describe("when it exists", () => { it("should return the track", async () => { - expect(await musicLibrary.track(track3.id)).toEqual(track3); + expect(await musicLibrary.track(track3.id)).toEqual({ ...track3, rating: { love: false, stars: 0 } },); }); }); }); @@ -221,7 +221,10 @@ describe("InMemoryMusicService", () => { ], }); const artist2 = anArtist({ name: "artist2", albums: [artist2_album1] }); - const artist3 = anArtist({ name: "artist3", albums: [artist3_album1, artist3_album2] }); + const artist3 = anArtist({ + name: "artist3", + albums: [artist3_album1, artist3_album2], + }); const artistWithNoAlbums = anArtist({ albums: [] }); const allAlbums = [artist1, artist2, artist3, artistWithNoAlbums].flatMap( @@ -258,7 +261,7 @@ describe("InMemoryMusicService", () => { }); expect(albums.total).toEqual(totalAlbumCount); - expect(albums.results.length).toEqual(3) + expect(albums.results.length).toEqual(3); // cannot really assert the results and they will change every time }); }); @@ -282,9 +285,9 @@ describe("InMemoryMusicService", () => { albumToAlbumSummary(artist1_album3), albumToAlbumSummary(artist1_album4), albumToAlbumSummary(artist1_album5), - + albumToAlbumSummary(artist2_album1), - + albumToAlbumSummary(artist3_album1), albumToAlbumSummary(artist3_album2), ], @@ -302,13 +305,11 @@ describe("InMemoryMusicService", () => { type: "alphabeticalByName", }) ).toEqual({ - results: - _.sortBy(allAlbums, 'name').map(albumToAlbumSummary), + results: _.sortBy(allAlbums, "name").map(albumToAlbumSummary), total: totalAlbumCount, }); }); }); - }); describe("fetching a page", () => { diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 43a9eb3..532c384 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -22,6 +22,7 @@ import { albumToAlbumSummary, Track, Genre, + Rating, } from "../src/music_service"; export class InMemoryMusicService implements MusicService { @@ -75,9 +76,11 @@ export class InMemoryMusicService implements MusicService { switch (q.type) { case "alphabeticalByArtist": return artist2Album; - case "alphabeticalByName": - return artist2Album.sort((a, b) => a.album.name.localeCompare(b.album.name)); - case "byGenre": + case "alphabeticalByName": + return artist2Album.sort((a, b) => + a.album.name.localeCompare(b.album.name) + ); + case "byGenre": return artist2Album.filter( (it) => it.album.genre?.id === q.genre ); @@ -107,18 +110,21 @@ export class InMemoryMusicService implements MusicService { A.map((it) => O.fromNullable(it.genre)), A.compact, A.uniq(fromEquals((x, y) => x.id === y.id)), - A.sort( - fromCompare((x, y) => ordString.compare(x.id, y.id)) - ) + A.sort(fromCompare((x, y) => ordString.compare(x.id, y.id))) ) ), tracks: (albumId: string) => - Promise.resolve(this.tracks.filter((it) => it.album.id === albumId)), + Promise.resolve( + this.tracks + .filter((it) => it.album.id === albumId) + .map((it) => ({ ...it, rating: { love: false, stars: 0 } })) + ), + rate: (_: string, _2: Rating) => Promise.resolve(false), track: (trackId: string) => pipe( this.tracks.find((it) => it.id === trackId), O.fromNullable, - O.map((it) => Promise.resolve(it)), + O.map((it) => Promise.resolve({ ...it, rating: { love: false, stars: 0 } })), O.getOrElse(() => Promise.reject(`Failed to find track with id ${trackId}`) ) @@ -139,10 +145,14 @@ export class InMemoryMusicService implements MusicService { playlists: async () => Promise.resolve([]), playlist: async (id: string) => Promise.reject(`No playlist with id ${id}`), - createPlaylist: async (_: string) => Promise.reject("Unsupported operation"), - deletePlaylist: async (_: string) => Promise.reject("Unsupported operation"), - addToPlaylist: async (_: string) => Promise.reject("Unsupported operation"), - removeFromPlaylist: async (_: string, _2: number[]) => Promise.reject("Unsupported operation"), + createPlaylist: async (_: string) => + Promise.reject("Unsupported operation"), + deletePlaylist: async (_: string) => + Promise.reject("Unsupported operation"), + addToPlaylist: async (_: string) => + Promise.reject("Unsupported operation"), + removeFromPlaylist: async (_: string, _2: number[]) => + Promise.reject("Unsupported operation"), similarSongs: async (_: string) => Promise.resolve([]), topSongs: async (_: string) => Promise.resolve([]), }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 98721ca..609f958 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1668,7 +1668,7 @@ describe("server", () => { "playlists", "genres", "random", - "starred", + "heart", "recentlyAdded", "recentlyPlayed", "mostPlayed", diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 6a16ff9..a06b9c4 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -24,8 +24,11 @@ import { iconArtURI, playlistAlbumArtURL, sonosifyMimeType, + ratingAsInt, + ratingFromInt, } from "../src/smapi"; +import { keys as i8nKeys } from '../src/i8n'; import { aService, getAppLinkMessage, @@ -54,6 +57,32 @@ import { iconForGenre } from "../src/icon"; const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); + +describe("rating to and from ints", () => { + describe("ratingAsInt", () => { + [ + { rating: { love: false, stars: 0 }, expectedValue: 100 }, + { rating: { love: true, stars: 0 }, expectedValue: 101 }, + { rating: { love: false, stars: 1 }, expectedValue: 110 }, + { rating: { love: true, stars: 1 }, expectedValue: 111 }, + { rating: { love: false, stars: 2 }, expectedValue: 120 }, + { rating: { love: true, stars: 2 }, expectedValue: 121 }, + { rating: { love: false, stars: 3 }, expectedValue: 130 }, + { rating: { love: true, stars: 3 }, expectedValue: 131 }, + { rating: { love: false, stars: 4 }, expectedValue: 140 }, + { rating: { love: true, stars: 4 }, expectedValue: 141 }, + { rating: { love: false, stars: 5 }, expectedValue: 150 }, + { rating: { love: true, stars: 5 }, expectedValue: 151 }, + ].forEach(({ rating, expectedValue }) => { + it(`should map ${JSON.stringify(rating)} to a ${expectedValue} and back`, () => { + const actualValue = ratingAsInt(rating); + expect(actualValue).toEqual(expectedValue); + expect(ratingFromInt(actualValue)).toEqual(rating); + }); + }); + }); +}); + describe("service config", () => { const bonobWithNoContextPath = url("http://localhost:1234"); const bonobWithContextPath = url("http://localhost:5678/some-context-path"); @@ -72,18 +101,18 @@ describe("service config", () => { pathname: PRESENTATION_MAP_ROUTE, }); + async function fetchStringsXml() { + const res = await request(server).get(stringsUrl.path()).send(); + + expect(res.status).toEqual(200); + + // removing the sonos xml ns as makes xpath queries with xpath-ts painful + return parseXML( + res.text.replace('xmlns="http://sonos.com/sonosapi"', "") + ); + } + describe(STRINGS_ROUTE, () => { - async function fetchStringsXml() { - const res = await request(server).get(stringsUrl.path()).send(); - - expect(res.status).toEqual(200); - - // removing the sonos xml ns as makes xpath queries with xpath-ts painful - return parseXML( - res.text.replace('xmlns="http://sonos.com/sonosapi"', "") - ); - } - it("should return xml for the strings", async () => { const xml = await fetchStringsXml(); @@ -120,15 +149,17 @@ describe("service config", () => { }); describe(PRESENTATION_MAP_ROUTE, () => { - it("should have an ArtWorkSizeMap for all sizes recommended by sonos", async () => { + async function presentationMapXml() { const res = await request(server).get(presentationUrl.path()).send(); - expect(res.status).toEqual(200); - // removing the sonos xml ns as makes xpath queries with xpath-ts painful - const xml = parseXML( + return parseXML( res.text.replace('xmlns="http://sonos.com/sonosapi"', "") ); + } + + it("should have an ArtWorkSizeMap for all sizes recommended by sonos", async () => { + const xml = await presentationMapXml(); const imageSizeMap = (size: string) => xpath.select( @@ -142,14 +173,7 @@ describe("service config", () => { }); it("should have an BrowseIconSizeMap for all sizes recommended by sonos", async () => { - const res = await request(server).get(presentationUrl.path()).send(); - - expect(res.status).toEqual(200); - - // removing the sonos xml ns as makes xpath queries with xpath-ts painful - const xml = parseXML( - res.text.replace('xmlns="http://sonos.com/sonosapi"', "") - ); + const xml = await presentationMapXml(); const imageSizeMap = (size: string) => xpath.select( @@ -161,6 +185,64 @@ describe("service config", () => { expect(imageSizeMap(size)).toEqual(`/size/${size}`); }); }); + + describe("NowPlayingRatings", () => { + it("should have Matches with propname = rating", async () => { + const xml = await presentationMapXml(); + + const matchElements = xpath.select( + `/Presentation/PresentationMap[@type="NowPlayingRatings"]/Match`, + xml + ) as Element[]; + + expect(matchElements.length).toBe(12); + + matchElements.forEach((match) => { + expect(match.getAttributeNode("propname")?.value).toEqual( + "rating" + ); + }); + }); + + it("should have Rating stringIds that are in strings.xml", async () => { + const xml = await presentationMapXml(); + + const ratingElements = xpath.select( + `/Presentation/PresentationMap[@type="NowPlayingRatings"]/Match/Ratings/Rating`, + xml + ) as Element[]; + + expect(ratingElements.length).toBeGreaterThan(1); + + ratingElements.forEach((rating) => { + const OnSuccessStringId = + rating.getAttributeNode("OnSuccessStringId")!.value; + const StringId = rating.getAttributeNode("StringId")!.value; + + expect(i8nKeys()).toContain(OnSuccessStringId); + expect(i8nKeys()).toContain(StringId); + }); + }); + + it("should have Rating Ids that are valid ratings as ints", async () => { + const xml = await presentationMapXml(); + + const ratingElements = xpath.select( + `/Presentation/PresentationMap[@type="NowPlayingRatings"]/Match/Ratings/Rating`, + xml + ) as Element[]; + + expect(ratingElements.length).toBeGreaterThan(1); + + ratingElements.forEach((ratingElement) => { + + const rating = ratingFromInt(Math.abs(Number.parseInt(ratingElement.getAttributeNode("Id")!.value))) + expect(rating.love).toBeDefined(); + expect(rating.stars).toBeGreaterThanOrEqual(0); + expect(rating.stars).toBeLessThanOrEqual(5); + }); + }); + }); }); }); }); @@ -264,13 +346,17 @@ describe("track", () => { genre: { id: "genre101", name: "some genre" }, }), artist: anArtist({ name: "great artist", id: uuid() }), - coverArt:"coverArt:887766" + coverArt: "coverArt:887766", + rating: { + love: true, + stars: 5 + } }); expect(track(bonobUrl, someTrack)).toEqual({ itemType: "track", id: `track:${someTrack.id}`, - mimeType: 'audio/flac', + mimeType: "audio/flac", title: someTrack.name, trackMetadata: { @@ -286,6 +372,14 @@ describe("track", () => { genreId: someTrack.album.genre?.id, trackNumber: someTrack.number, }, + dynamic: { + property: [ + { + name: "rating", + value: `${ratingAsInt(someTrack.rating)}`, + }, + ], + }, }); }); }); @@ -328,7 +422,10 @@ describe("playlistAlbumArtURL", () => { it("should return question mark icon", () => { const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const playlist = aPlaylist({ - entries: [aTrack({ coverArt: undefined }), aTrack({ coverArt: undefined })], + entries: [ + aTrack({ coverArt: undefined }), + aTrack({ coverArt: undefined }), + ], }); expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( @@ -403,7 +500,9 @@ describe("playlistAlbumArtURL", () => { }); describe("defaultAlbumArtURI", () => { - const bonobUrl = new URLBuilder("http://bonob.example.com:8080/context?search=yes"); + const bonobUrl = new URLBuilder( + "http://bonob.example.com:8080/context?search=yes" + ); describe("when there is an album coverArt", () => { it("should use it in the image url", () => { @@ -421,10 +520,7 @@ describe("defaultAlbumArtURI", () => { describe("when there is no album coverArt", () => { it("should return a vinly icon image", () => { expect( - defaultAlbumArtURI( - bonobUrl, - anAlbum({ coverArt: undefined }) - ).href() + defaultAlbumArtURI(bonobUrl, anAlbum({ coverArt: undefined })).href() ).toEqual( "http://bonob.example.com:8080/context/icon/vinyl/size/legacy?search=yes" ); @@ -473,6 +569,7 @@ describe("api", () => { removeFromPlaylist: jest.fn(), scrobble: jest.fn(), nowPlaying: jest.fn(), + rate: jest.fn(), }; const accessTokens = { mint: jest.fn(), @@ -868,6 +965,18 @@ describe("api", () => { albumArtURI: iconArtURI(bonobUrl, "albums").href(), itemType: "albumList", }, + { + id: "randomAlbums", + title: "Random", + albumArtURI: iconArtURI(bonobUrl, "random").href(), + itemType: "albumList", + }, + { + id: "favouriteAlbums", + title: "Favourites", + albumArtURI: iconArtURI(bonobUrl, "heart").href(), + itemType: "albumList", + }, { id: "playlists", title: "Playlists", @@ -885,18 +994,6 @@ describe("api", () => { albumArtURI: iconArtURI(bonobUrl, "genres").href(), itemType: "container", }, - { - id: "randomAlbums", - title: "Random", - albumArtURI: iconArtURI(bonobUrl, "random").href(), - itemType: "albumList", - }, - { - id: "starredAlbums", - title: "Starred", - albumArtURI: iconArtURI(bonobUrl, "starred").href(), - itemType: "albumList", - }, { id: "recentlyAdded", title: "Recently added", @@ -955,6 +1052,18 @@ describe("api", () => { albumArtURI: iconArtURI(bonobUrl, "albums").href(), itemType: "albumList", }, + { + id: "randomAlbums", + title: "Willekeurig", + albumArtURI: iconArtURI(bonobUrl, "random").href(), + itemType: "albumList", + }, + { + id: "favouriteAlbums", + title: "Favorieten", + albumArtURI: iconArtURI(bonobUrl, "heart").href(), + itemType: "albumList", + }, { id: "playlists", title: "Afspeellijsten", @@ -972,18 +1081,6 @@ describe("api", () => { albumArtURI: iconArtURI(bonobUrl, "genres").href(), itemType: "container", }, - { - id: "randomAlbums", - title: "Willekeurig", - albumArtURI: iconArtURI(bonobUrl, "random").href(), - itemType: "albumList", - }, - { - id: "starredAlbums", - title: "Favorieten", - albumArtURI: iconArtURI(bonobUrl, "starred").href(), - itemType: "albumList", - }, { id: "recentlyAdded", title: "Onlangs toegevoegd", @@ -1568,7 +1665,7 @@ describe("api", () => { }); }); - describe("asking for starred albums", () => { + describe("asking for favourite albums", () => { const albums = [rock2, rock1, pop2]; beforeEach(() => { @@ -1585,7 +1682,7 @@ describe("api", () => { }; const result = await ws.getMetadataAsync({ - id: "starredAlbums", + id: "favouriteAlbums", ...paging, }); @@ -2325,42 +2422,90 @@ describe("api", () => { }); describe("asking for a track", () => { - it("should return the track", async () => { - const track = aTrack(); + describe("that has a love", () => { + it("should return the track", async () => { + const track = aTrack(); - musicLibrary.track.mockResolvedValue(track); + musicLibrary.track.mockResolvedValue(track); - const root = await ws.getExtendedMetadataAsync({ - id: `track:${track.id}`, - }); + const root = await ws.getExtendedMetadataAsync({ + id: `track:${track.id}`, + }); - expect(root[0]).toEqual({ - getExtendedMetadataResult: { - mediaMetadata: { - id: `track:${track.id}`, - itemType: "track", - title: track.name, - mimeType: track.mimeType, - trackMetadata: { - artistId: `artist:${track.artist.id}`, - artist: track.artist.name, - albumId: `album:${track.album.id}`, - albumArtist: track.artist.name, - albumArtistId: `artist:${track.artist.id}`, - album: track.album.name, - genre: track.genre?.name, - genreId: track.genre?.id, - duration: track.duration, - albumArtURI: defaultAlbumArtURI( - bonobUrlWithAccessToken, - track - ).href(), - trackNumber: track.number, + expect(root[0]).toEqual({ + getExtendedMetadataResult: { + mediaMetadata: { + id: `track:${track.id}`, + itemType: "track", + title: track.name, + mimeType: track.mimeType, + trackMetadata: { + artistId: `artist:${track.artist.id}`, + artist: track.artist.name, + albumId: `album:${track.album.id}`, + albumArtist: track.artist.name, + albumArtistId: `artist:${track.artist.id}`, + album: track.album.name, + genre: track.genre?.name, + genreId: track.genre?.id, + duration: track.duration, + albumArtURI: defaultAlbumArtURI( + bonobUrlWithAccessToken, + track + ).href(), + trackNumber: track.number, + }, + dynamic: { + property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }], + }, }, }, - }, + }); + expect(musicLibrary.track).toHaveBeenCalledWith(track.id); + }); + }); + + describe("that does not have a love", () => { + it("should return the track", async () => { + const track = aTrack(); + + musicLibrary.track.mockResolvedValue(track); + + const root = await ws.getExtendedMetadataAsync({ + id: `track:${track.id}`, + }); + + expect(root[0]).toEqual({ + getExtendedMetadataResult: { + mediaMetadata: { + id: `track:${track.id}`, + itemType: "track", + title: track.name, + mimeType: track.mimeType, + trackMetadata: { + artistId: `artist:${track.artist.id}`, + artist: track.artist.name, + albumId: `album:${track.album.id}`, + albumArtist: track.artist.name, + albumArtistId: `artist:${track.artist.id}`, + album: track.album.name, + genre: track.genre?.name, + genreId: track.genre?.id, + duration: track.duration, + albumArtURI: defaultAlbumArtURI( + bonobUrlWithAccessToken, + track + ).href(), + trackNumber: track.number, + }, + dynamic: { + property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }], + }, + }, + }, + }); + expect(musicLibrary.track).toHaveBeenCalledWith(track.id); }); - expect(musicLibrary.track).toHaveBeenCalledWith(track.id); }); }); @@ -2471,7 +2616,7 @@ describe("api", () => { getMediaURIResult: bonobUrl .append({ pathname: `/stream/track/${trackId}`, - searchParams: { "bat": accessToken } + searchParams: { bat: accessToken }, }) .href(), }); @@ -2788,6 +2933,64 @@ describe("api", () => { }); }); + describe("rateItem", () => { + let ws: Client; + + beforeEach(async () => { + musicService.login.mockResolvedValue(musicLibrary); + accessTokens.mint.mockReturnValue(accessToken); + + ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + ws.addSoapHeader({ credentials: someCredentials(authToken) }); + }); + + describe("rating a track with a positive rating value", () => { + const trackId = "123"; + const ratingIntValue = 31; + + it("should give the track a love", async () => { + musicLibrary.rate.mockResolvedValue(true); + + const result = await ws.rateItemAsync({ + id: `track:${trackId}`, + rating: ratingIntValue, + }); + + expect(result[0]).toEqual({ + rateItemResult: { shouldSkip: false }, + }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.rate).toHaveBeenCalledWith(trackId, ratingFromInt(ratingIntValue)); + }); + }); + + describe("rating a track with a negative rating value", () => { + const trackId = "123"; + const ratingIntValue = -20; + + it("should give the track a love", async () => { + musicLibrary.rate.mockResolvedValue(true); + + const result = await ws.rateItemAsync({ + id: `track:${trackId}`, + rating: ratingIntValue, + }); + + expect(result[0]).toEqual({ + rateItemResult: { shouldSkip: false }, + }); + expect(musicService.login).toHaveBeenCalledWith(authToken); + expect(accessTokens.mint).toHaveBeenCalledWith(authToken); + expect(musicLibrary.rate).toHaveBeenCalledWith(trackId, ratingFromInt(Math.abs(ratingIntValue))); + }); + }); + + }); + describe("setPlayedSeconds", () => { let ws: Client; @@ -2812,7 +3015,7 @@ describe("api", () => { }: { trackId: string; secondsPlayed: number; - shouldMarkNowPlaying: boolean, + shouldMarkNowPlaying: boolean; }) { it("should scrobble", async () => { musicLibrary.scrobble.mockResolvedValue(true); @@ -2827,7 +3030,7 @@ describe("api", () => { expect(accessTokens.mint).toHaveBeenCalledWith(authToken); expect(musicLibrary.track).toHaveBeenCalledWith(trackId); expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId); - if(shouldMarkNowPlaying) { + if (shouldMarkNowPlaying) { expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); } else { expect(musicLibrary.nowPlaying).not.toHaveBeenCalled(); @@ -2842,7 +3045,7 @@ describe("api", () => { }: { trackId: string; secondsPlayed: number; - shouldMarkNowPlaying: boolean, + shouldMarkNowPlaying: boolean; }) { it("should scrobble", async () => { const result = await ws.setPlayedSecondsAsync({ @@ -2855,7 +3058,7 @@ describe("api", () => { expect(accessTokens.mint).toHaveBeenCalledWith(authToken); expect(musicLibrary.track).toHaveBeenCalledWith(trackId); expect(musicLibrary.scrobble).not.toHaveBeenCalled(); - if(shouldMarkNowPlaying) { + if (shouldMarkNowPlaying) { expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId); } else { expect(musicLibrary.nowPlaying).not.toHaveBeenCalled(); @@ -2871,23 +3074,43 @@ describe("api", () => { }); describe("when the seconds played is 30 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 30, shouldMarkNowPlaying: true }); + itShouldScroble({ + trackId, + secondsPlayed: 30, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is > 30 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 90, shouldMarkNowPlaying: true }); + itShouldScroble({ + trackId, + secondsPlayed: 90, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is < 30 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 29, shouldMarkNowPlaying: true }); + itShouldNotScroble({ + trackId, + secondsPlayed: 29, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is 1 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 1, shouldMarkNowPlaying: true }); + itShouldNotScroble({ + trackId, + secondsPlayed: 1, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is 0 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 0, shouldMarkNowPlaying: false }); + itShouldNotScroble({ + trackId, + secondsPlayed: 0, + shouldMarkNowPlaying: false, + }); }); }); @@ -2899,23 +3122,43 @@ describe("api", () => { }); describe("when the seconds played is 30 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 30, shouldMarkNowPlaying: true }); + itShouldScroble({ + trackId, + secondsPlayed: 30, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is > 30 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 90, shouldMarkNowPlaying: true }); + itShouldScroble({ + trackId, + secondsPlayed: 90, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is < 30 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 29, shouldMarkNowPlaying: true }); + itShouldNotScroble({ + trackId, + secondsPlayed: 29, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is 1 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 1, shouldMarkNowPlaying: true }); + itShouldNotScroble({ + trackId, + secondsPlayed: 1, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is 0 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 0, shouldMarkNowPlaying: false }); + itShouldNotScroble({ + trackId, + secondsPlayed: 0, + shouldMarkNowPlaying: false, + }); }); }); @@ -2927,27 +3170,51 @@ describe("api", () => { }); describe("when the seconds played is 29 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 30, shouldMarkNowPlaying: true }); + itShouldScroble({ + trackId, + secondsPlayed: 30, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is > 29 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 30, shouldMarkNowPlaying: true }); + itShouldScroble({ + trackId, + secondsPlayed: 30, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is 10 seconds", () => { - itShouldScroble({ trackId, secondsPlayed: 10, shouldMarkNowPlaying: true }); + itShouldScroble({ + trackId, + secondsPlayed: 10, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is < 10 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 9, shouldMarkNowPlaying: true }); + itShouldNotScroble({ + trackId, + secondsPlayed: 9, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is 1 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 1, shouldMarkNowPlaying: true }); + itShouldNotScroble({ + trackId, + secondsPlayed: 1, + shouldMarkNowPlaying: true, + }); }); describe("when the seconds played is 0 seconds", () => { - itShouldNotScroble({ trackId, secondsPlayed: 0, shouldMarkNowPlaying: false }); + itShouldNotScroble({ + trackId, + secondsPlayed: 0, + shouldMarkNowPlaying: false, + }); }); }); }); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index f283756..add0a81 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -15,6 +15,7 @@ import { asURLSearchParams, splitCoverArtId, cachingImageFetcher, + asTrack, } from "../src/subsonic"; import encryption from "../src/encryption"; @@ -41,6 +42,7 @@ import { PlaylistSummary, Playlist, SimilarArtist, + Rating, } from "../src/music_service"; import { aGenre, @@ -175,8 +177,8 @@ describe("cachingImageFetcher", () => { it("should fetch the image from the source and then cache and return it", async () => { const dir = tmp.dirSync(); const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); - const jpgImage = Buffer.from("jpg-image", 'utf-8'); - const pngImage = Buffer.from("png-image", 'utf-8'); + const jpgImage = Buffer.from("jpg-image", "utf-8"); + const pngImage = Buffer.from("png-image", "utf-8"); delegate.mockResolvedValue({ contentType: "image/jpeg", data: jpgImage }); const png = jest.fn(); @@ -230,214 +232,217 @@ describe("cachingImageFetcher", () => { }); }); -const ok = (data: string) => ({ +const ok = (data: string | object) => ({ status: 200, data, }); -const similarArtistXml = (similarArtist: SimilarArtist) => { +const asSimilarArtistJson = (similarArtist: SimilarArtist) => { if (similarArtist.inLibrary) - return ``; + return { + id: similarArtist.id, + name: similarArtist.name, + albumCount: 3, + }; else - return ``; + return { + id: -1, + name: similarArtist.name, + albumCount: 3, + }; }; -const getArtistInfoXml = ( - artist: Artist -) => ` - - - - - ${artist.image.small || ""} - ${artist.image.medium || ""} - ${artist.image.large || ""} - ${artist.similarArtists.map(similarArtistXml).join("")} - - `; +const getArtistInfoJson = (artist: Artist) => + subsonicOK({ + artistInfo2: { + smallImageUrl: artist.image.small, + mediumImageUrl: artist.image.medium, + largeImageUrl: artist.image.large, + similarArtist: artist.similarArtists.map(asSimilarArtistJson), + }, + }); const maybeIdFromCoverArtId = (coverArt: string | undefined) => coverArt ? splitCoverArtId(coverArt)[1] : ""; -const albumXml = ( +const asAlbumJson = ( artist: Artist, album: AlbumSummary, tracks: Track[] = [] -) => `${tracks.map(songXml).join("")}`; +) => ({ + id: album.id, + parent: artist.id, + isDir: "true", + title: album.name, + name: album.name, + album: album.name, + artist: artist.name, + genre: album.genre?.name, + coverArt: maybeIdFromCoverArtId(album.coverArt), + duration: "123", + playCount: "4", + year: album.year, + created: "2021-01-07T08:19:55.834207205Z", + artistId: artist.id, + songCount: "19", + isVideo: false, + song: tracks.map(asSongJson), +}); -const songXml = (track: Track) => ``; +const asSongJson = (track: Track) => ({ + id: track.id, + parent: track.album.id, + title: track.name, + album: track.album.name, + artist: track.artist.name, + track: track.number, + genre: track.genre?.name, + isDir: "false", + coverArt: maybeIdFromCoverArtId(track.coverArt), + 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: track.album.id, + artistId: track.artist.id, + type: "music", + starred: track.rating.love ? "sometime" : undefined, + userRating: track.rating.stars, + year: "" +}); -const albumListXml = ( - albums: [Artist, Album][] -) => ` - - ${albums - .map(([artist, album]) => albumXml(artist, album)) - .join("")} - - `; +const getAlbumListJson = (albums: [Artist, Album][]) => + subsonicOK({ + albumList2: { + album: albums.map(([artist, album]) => asAlbumJson(artist, album)), + }, + }); -const artistXml = (artist: Artist) => ` - ${artist.albums - .map((album) => - albumXml(artist, album) - ) - .join("")} - `; +const asArtistJson = (artist: Artist) => ({ + id: artist.id, + name: artist.name, + albumCount: artist.albums.length, + artistImageUrl: "...", + album: artist.albums.map((it) => asAlbumJson(artist, it)), +}); -const getArtistXml = ( - artist: Artist -) => ` - ${artistXml(artist)} - `; +const getArtistJson = (artist: Artist) => + subsonicOK({ + artist: asArtistJson(artist), + }); -const genreXml = (genre: { name: string; albumCount: number }) => - `${genre.name}`; +const asGenreJson = (genre: { name: string; albumCount: number }) => ({ + songCount: 1475, + albumCount: genre.albumCount, + value: genre.name, +}); -const genresXml = ( - genres: { name: string; albumCount: number }[] -) => ` - - ${genres.map(genreXml).join("")} - - `; +const getGenresJson = (genres: { name: string; albumCount: number }[]) => + subsonicOK({ + genres: { + genre: genres.map(asGenreJson), + }, + }); -const getAlbumXml = ( - artist: Artist, - album: Album, - tracks: Track[] -) => ` - ${albumXml( - artist, - album, - tracks - )} - `; +const getAlbumJson = (artist: Artist, album: Album, tracks: Track[]) => + subsonicOK({ album: asAlbumJson(artist, album, tracks) }); -const getSongXml = ( - track: Track -) => ` - ${songXml( - track - )} - `; +const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); -const similarSongsXml = ( - tracks: Track[] -) => ` - - ${tracks.map(songXml).join("")} - - `; +// const getStarredJson = ({ songIds }: { songIds: string[] }) => subsonicOK({starred2: { +// album: [], +// song: songIds.map((id) => ({ id })), +// }}) -const topSongsXml = ( - tracks: Track[] -) => ` - - ${tracks.map(songXml).join("")} - - `; +const subsonicOK = (body: any = {}) => ({ + "subsonic-response": { + status: "ok", + version: "1.16.1", + type: "navidrome", + serverVersion: "0.45.1 (c55e6590)", + ...body, + }, +}); + +const getSimilarSongsJson = (tracks: Track[]) => + subsonicOK({ similarSongs2: { song: tracks.map(asSongJson) } }); + +const getTopSongsJson = (tracks: Track[]) => + subsonicOK({ topSongs: { song: tracks.map(asSongJson) } }); export type ArtistWithAlbum = { artist: Artist; album: Album; }; -const playlistXml = (playlist: PlaylistSummary) => - ``; +const asPlaylistJson = (playlist: PlaylistSummary) => ({ + id: playlist.id, + name: playlist.name, + songCount: 1, + duration: 190, + public: true, + owner: "bob", + created: "2021-05-06T02:07:24.308007023Z", + changed: "2021-05-06T02:08:06Z", +}); -const getPlayLists = ( - playlists: PlaylistSummary[] -) => ` - - ${playlists.map(playlistXml).join("")} - -`; +const getPlayListsJson = (playlists: PlaylistSummary[]) => + subsonicOK({ + playlists: { + playlist: playlists.map(asPlaylistJson), + }, + }); -const error = (code: string, message: string) => - ` - - - `; +const createPlayListJson = (playlist: PlaylistSummary) => + subsonicOK({ + playlist: asPlaylistJson(playlist), + }); -const createPlayList = ( - playlist: PlaylistSummary -) => ` - ${playlistXml(playlist)} - `; +const getPlayListJson = (playlist: Playlist) => + subsonicOK({ + playlist: { + id: playlist.id, + name: playlist.name, + songCount: playlist.entries.length, + duration: 627, + public: true, + owner: "bob", + created: "2021-05-06T02:07:30.460465988Z", + changed: "2021-05-06T02:40:04Z", + entry: playlist.entries.map((it) => ({ + id: it.id, + parent: "...", + isDir: false, + title: it.name, + album: it.album.name, + artist: it.artist.name, + track: it.number, + year: it.album.year, + genre: it.album.genre?.name, + coverArt: splitCoverArtId(it.coverArt!)[1], + size: 123, + contentType: it.mimeType, + suffix: "mp3", + duration: it.duration, + bitRate: 128, + path: "...", + discNumber: 1, + created: "2019-09-04T04:07:00.138169924Z", + albumId: it.album.id, + artistId: it.artist.id, + type: "music", + isVideo: false, + starred: it.rating.love ? "sometime" : undefined, + userRating: it.rating.stars, + })), + }, + }); -const getPlayList = ( - playlist: Playlist -) => ` - - ${playlist.entries - .map( - (it) => `` - ) - .join("")} - -`; - -const searchResult3 = ({ +const getSearchResult3Json = ({ artists, albums, tracks, @@ -445,22 +450,16 @@ const searchResult3 = ({ artists: Artist[]; albums: ArtistWithAlbum[]; tracks: Track[]; -}>) => ` - - ${(artists || []) - .map((it) => - artistXml({ - ...it, - albums: [], - }) - ) - .join("")} - ${(albums || []).map((it) => albumXml(it.artist, it.album, [])).join("")} - ${(tracks || []).map((it) => songXml(it)).join("")} - -`; +}>) => + subsonicOK({ + searchResult3: { + artist: (artists || []).map((it) => asArtistJson({ ...it, albums: [] })), + album: (albums || []).map((it) => asAlbumJson(it.artist, it.album, [])), + song: (tracks || []).map((it) => asSongJson(it)), + }, + }); -const getArtistsXml = (artists: Artist[]) => { +const asArtistsJson = (artists: Artist[]) => { const as: Artist[] = []; const bs: Artist[] = []; const cs: Artist[] = []; @@ -483,30 +482,66 @@ const getArtistsXml = (artists: Artist[]) => { } }); - const artistSummaryXml = (artist: Artist) => - ``; + const asArtistSummary = (artist: Artist) => ({ + id: artist.id, + name: artist.name, + albumCount: artist.albums.length, + }); - return ` - - - ${as.map(artistSummaryXml).join("")} - - - ${bs.map(artistSummaryXml).join("")} - - - ${cs.map(artistSummaryXml).join("")} - - - ${rest.map(artistSummaryXml).join("")} - - - `; + return subsonicOK({ + artists: { + index: [ + { + name: "A", + artist: as.map(asArtistSummary), + }, + { + name: "B", + artist: bs.map(asArtistSummary), + }, + { + name: "C", + artist: cs.map(asArtistSummary), + }, + { + name: "D-Z", + artist: rest.map(asArtistSummary), + }, + ], + }, + }); }; -const EMPTY = ``; +const error = (code: string, message: string) => ({ + "subsonic-response": { + status: "failed", + version: "1.16.1", + type: "navidrome", + serverVersion: "0.45.1 (c55e6590)", + error: { code, message }, + }, +}); -const PING_OK = ``; +const EMPTY = { + "subsonic-response": { + status: "ok", + version: "1.16.1", + type: "navidrome", + serverVersion: "0.45.1 (c55e6590)", + }, +}; + +const FAILURE = { + "subsonic-response": { + status: "failed", + version: "1.16.1", + type: "navidrome", + serverVersion: "0.45.1 (c55e6590)", + error: { code: 10, message: 'Missing required parameter "v"' }, + }, +}; + +const PING_OK = subsonicOK({}); describe("splitCoverArtId", () => { it("should split correctly", () => { @@ -528,6 +563,27 @@ describe("splitCoverArtId", () => { }); }); +describe("asTrack", () => { + const album = anAlbum(); + const track = aTrack(); + + describe("invalid rating.stars values", () => { + describe("a value greater than 5", () => { + it("should be returned as 0", () => { + const result = asTrack(album, { ...asSongJson(track), userRating: 6 }); + expect(result.rating.stars).toEqual(0) + }); + }); + + describe("a value less than 0", () => { + it("should be returned as 0", () => { + const result = asTrack(album, { ...asSongJson(track), userRating: -1 }); + expect(result.rating.stars).toEqual(0) + }); + }); + }); +}); + describe("Subsonic", () => { const url = "http://127.0.0.22:4567"; const username = "user1"; @@ -562,6 +618,12 @@ describe("Subsonic", () => { t: t(password, salt), s: salt, }; + + const authParamsPlusJson = { + ...authParams, + f: "json", + }; + const headers = { "User-Agent": "bonob", }; @@ -581,7 +643,7 @@ describe("Subsonic", () => { expect(token.userId).toEqual(username); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/ping.view`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); }); @@ -591,9 +653,7 @@ describe("Subsonic", () => { it("should be able to generate a token and then login using it", async () => { (axios.get as jest.Mock).mockResolvedValue({ status: 200, - data: ` - - `, + data: error("40", "Wrong username or password"), }); const token = await navidrome.generateToken({ username, password }); @@ -609,7 +669,7 @@ describe("Subsonic", () => { beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(genresXml([])))); + .mockImplementationOnce(() => Promise.resolve(ok(getGenresJson([])))); }); it("should return empty array", async () => { @@ -622,7 +682,7 @@ describe("Subsonic", () => { expect(result).toEqual([]); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); }); @@ -637,7 +697,9 @@ describe("Subsonic", () => { beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(genresXml(genres)))); + .mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); }); it("should return them alphabetically sorted", async () => { @@ -650,7 +712,7 @@ describe("Subsonic", () => { expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); }); @@ -668,7 +730,9 @@ describe("Subsonic", () => { beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(genresXml(genres)))); + .mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); }); it("should return them alphabetically sorted", async () => { @@ -686,7 +750,7 @@ describe("Subsonic", () => { ]); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getGenres`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); }); @@ -719,10 +783,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ); }); @@ -734,7 +798,7 @@ describe("Subsonic", () => { .then((it) => it.artist(artist.id)); expect(result).toEqual({ - id: artist.id, + id: `${artist.id}`, name: artist.name, image: { small: undefined, @@ -747,7 +811,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, }), headers, @@ -755,7 +819,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, @@ -786,10 +850,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ); }); @@ -814,7 +878,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, }), headers, @@ -822,7 +886,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, @@ -851,10 +915,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ); }); @@ -879,7 +943,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, }), headers, @@ -887,7 +951,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, @@ -916,10 +980,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ); }); @@ -944,7 +1008,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, }), headers, @@ -952,7 +1016,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, @@ -976,10 +1040,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ); }); @@ -1000,7 +1064,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, }), headers, @@ -1008,7 +1072,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, @@ -1030,10 +1094,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ); }); @@ -1054,7 +1118,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, }), headers, @@ -1062,7 +1126,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, @@ -1082,10 +1146,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ); }); @@ -1106,7 +1170,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, }), headers, @@ -1114,7 +1178,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artist.id, count: 50, includeNotPresent: true, @@ -1133,16 +1197,23 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve( - ok(` - - - - - - - - - `) + ok( + subsonicOK({ + artists: { + index: [ + { + name: "#", + }, + { + name: "A", + }, + { + name: "B", + }, + ], + }, + }) + ) ) ); }); @@ -1167,10 +1238,11 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve( - ok(` - - - `) + ok( + subsonicOK({ + artists: {}, + }) + ) ) ); }); @@ -1192,19 +1264,28 @@ describe("Subsonic", () => { describe("when there is one index and one artist", () => { const artist1 = anArtist(); - const getArtistsXml = ` - - - - - - `; + const asArtistsJson = subsonicOK({ + artists: { + index: [ + { + name: "#", + artist: [ + { + id: artist1.id, + name: artist1.name, + albumCount: 22, + }, + ], + }, + ], + }, + }); describe("when it all fits on one page", () => { beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(getArtistsXml))); + .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson))); }); it("should return the single artist", async () => { @@ -1225,7 +1306,7 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); }); @@ -1244,7 +1325,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml(artists))) + Promise.resolve(ok(asArtistsJson(artists))) ); }); @@ -1268,7 +1349,7 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); }); @@ -1279,7 +1360,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml(artists))) + Promise.resolve(ok(asArtistsJson(artists))) ); }); @@ -1298,7 +1379,7 @@ describe("Subsonic", () => { expect(artists).toEqual({ results: expectedResults, total: 4 }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); }); @@ -1319,12 +1400,12 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml([artist]))) + Promise.resolve(ok(asArtistsJson([artist]))) ) .mockImplementationOnce(() => Promise.resolve( ok( - albumListXml([ + getAlbumListJson([ [artist, album1], // album2 is not Pop [artist, album3], @@ -1353,13 +1434,13 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "byGenre", genre: "Pop", size: 500, @@ -1375,12 +1456,12 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml([artist]))) + Promise.resolve(ok(asArtistsJson([artist]))) ) .mockImplementationOnce(() => Promise.resolve( ok( - albumListXml([ + getAlbumListJson([ [artist, album3], [artist, album2], [artist, album1], @@ -1404,13 +1485,13 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "newest", size: 500, offset: 0, @@ -1425,12 +1506,12 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml([artist]))) + Promise.resolve(ok(asArtistsJson([artist]))) ) .mockImplementationOnce(() => Promise.resolve( ok( - albumListXml([ + getAlbumListJson([ [artist, album3], [artist, album2], // album1 never played @@ -1454,13 +1535,13 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "recent", size: 500, offset: 0, @@ -1475,12 +1556,12 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml([artist]))) + Promise.resolve(ok(asArtistsJson([artist]))) ) .mockImplementationOnce( () => // album1 never played - Promise.resolve(ok(albumListXml([[artist, album2]]))) + Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) // album3 never played ); }); @@ -1499,13 +1580,13 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "frequent", size: 500, offset: 0, @@ -1528,10 +1609,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml(artists))) + Promise.resolve(ok(asArtistsJson(artists))) ) .mockImplementationOnce(() => - Promise.resolve(ok(albumListXml(asArtistAlbumPairs(artists)))) + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) ); }); @@ -1553,13 +1634,13 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: 0, @@ -1581,10 +1662,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml(artists))) + Promise.resolve(ok(asArtistsJson(artists))) ) .mockImplementationOnce(() => - Promise.resolve(ok(albumListXml(asArtistAlbumPairs(artists)))) + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) ); }); @@ -1606,13 +1687,13 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: 0, @@ -1651,10 +1732,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml(artists))) + Promise.resolve(ok(asArtistsJson(artists))) ) .mockImplementationOnce(() => - Promise.resolve(ok(albumListXml(asArtistAlbumPairs(artists)))) + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) ); const q: AlbumQuery = { @@ -1674,13 +1755,13 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: 0, @@ -1695,12 +1776,12 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml(artists))) + Promise.resolve(ok(asArtistsJson(artists))) ) .mockImplementationOnce(() => Promise.resolve( ok( - albumListXml([ + getAlbumListJson([ [artist1, artist1.albums[2]!], [artist2, artist2.albums[0]!], // due to pre-fetch will get next 2 albums also @@ -1728,13 +1809,13 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: 2, @@ -1769,12 +1850,12 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml(artists))) + Promise.resolve(ok(asArtistsJson(artists))) ) .mockImplementationOnce(() => Promise.resolve( ok( - albumListXml([ + getAlbumListJson([ [artist1, album1], [artist1, album2], [artist1, album3], @@ -1804,7 +1885,7 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); @@ -1812,7 +1893,7 @@ describe("Subsonic", () => { `${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, @@ -1828,12 +1909,12 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml(artists))) + Promise.resolve(ok(asArtistsJson(artists))) ) .mockImplementationOnce(() => Promise.resolve( ok( - albumListXml([ + getAlbumListJson([ [artist1, album1], [artist1, album2], // album3 & album5 is returned due to the prefetch @@ -1864,7 +1945,7 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); @@ -1872,7 +1953,7 @@ describe("Subsonic", () => { `${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, @@ -1888,12 +1969,12 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml(artists))) + Promise.resolve(ok(asArtistsJson(artists))) ) .mockImplementationOnce(() => Promise.resolve( ok( - albumListXml([ + getAlbumListJson([ // album1 is on the first page // album2 is on the first page [artist1, album3], @@ -1923,7 +2004,7 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); @@ -1931,7 +2012,7 @@ describe("Subsonic", () => { `${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, @@ -1951,7 +2032,7 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve( ok( - getArtistsXml([ + asArtistsJson([ // artist1 has lost 2 albums on the getArtists end point { ...artist1, albums: [album1, album2] }, artist2, @@ -1962,7 +2043,7 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve( ok( - albumListXml([ + getAlbumListJson([ [artist1, album1], [artist1, album2], [artist1, album3], @@ -1992,7 +2073,7 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); @@ -2000,7 +2081,7 @@ describe("Subsonic", () => { `${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, @@ -2018,7 +2099,7 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve( ok( - getArtistsXml([ + asArtistsJson([ // artist1 has lost 2 albums on the getArtists end point { ...artist1, albums: [album1, album2] }, artist2, @@ -2029,7 +2110,7 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve( ok( - albumListXml([ + getAlbumListJson([ [artist1, album1], [artist1, album2], [artist1, album3], @@ -2059,7 +2140,7 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); @@ -2067,7 +2148,7 @@ describe("Subsonic", () => { `${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, @@ -2085,7 +2166,7 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve( ok( - getArtistsXml([ + asArtistsJson([ // artist1 has lost 2 albums on the getArtists end point { ...artist1, albums: [album1, album2] }, artist2, @@ -2096,7 +2177,7 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve( ok( - albumListXml([ + getAlbumListJson([ [artist1, album3], [artist1, album4], [artist2, album5], @@ -2124,7 +2205,7 @@ describe("Subsonic", () => { }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); @@ -2132,7 +2213,7 @@ describe("Subsonic", () => { `${url}/rest/getAlbumList2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, type: "alphabeticalByArtist", size: 500, offset: q._index, @@ -2165,7 +2246,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, tracks))) + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) ); }); @@ -2180,7 +2261,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: album.id, }), headers, @@ -2191,7 +2272,7 @@ describe("Subsonic", () => { describe("getting tracks", () => { describe("for an album", () => { - describe("when the album has multiple tracks", () => { + describe("when the album has multiple tracks, some of which are rated", () => { const hipHop = asGenre("Hip-Hop"); const tripHop = asGenre("Trip-Hop"); @@ -2203,34 +2284,50 @@ describe("Subsonic", () => { albums: [album], }); - const tracks = [ - aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: hipHop, - }), - aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: hipHop, - }), - aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: tripHop, - }), - aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: tripHop, - }), - ]; + const track1 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: hipHop, + rating: { + love: true, + stars: 3, + }, + }); + const track2 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: hipHop, + rating: { + love: false, + stars: 0, + }, + }); + const track3 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: tripHop, + rating: { + love: true, + stars: 5, + }, + }); + const track4 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: tripHop, + rating: { + love: false, + stars: 1, + }, + }); + + const tracks = [track1, track2, track3, track4]; beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, tracks))) + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) ); }); @@ -2241,11 +2338,11 @@ describe("Subsonic", () => { .then((it) => navidrome.login(it.authToken)) .then((it) => it.tracks(album.id)); - expect(result).toEqual(tracks); + expect(result).toEqual([track1, track2, track3, track4]); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: album.id, }), headers, @@ -2268,19 +2365,19 @@ describe("Subsonic", () => { albums: [album], }); - const tracks = [ - aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: flipFlop, - }), - ]; + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: flipFlop, + }); + + const tracks = [track]; beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, tracks))) + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) ); }); @@ -2291,11 +2388,11 @@ describe("Subsonic", () => { .then((it) => navidrome.login(it.authToken)) .then((it) => it.tracks(album.id)); - expect(result).toEqual(tracks); + expect(result).toEqual([track]); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: album.id, }), headers, @@ -2318,7 +2415,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, tracks))) + Promise.resolve(ok(getAlbumJson(artist, album, tracks))) ); }); @@ -2333,7 +2430,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: album.id, }), headers, @@ -2353,44 +2450,103 @@ describe("Subsonic", () => { albums: [album], }); - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - }); + describe("that is starred", () => { + it("should return the track", async () => { + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + rating: { + love: true, + stars: 4, + }, + }); - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track)))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, []))) - ); - }); + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); - it("should return the track", async () => { - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.track(track.id)); + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.track(track.id)); - expect(result).toEqual(track); + expect(result).toEqual({ + ...track, + rating: { love: true, stars: 4 }, + }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { - params: asURLSearchParams({ - ...authParams, - id: track.id, - }), - headers, + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); }); + }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { - params: asURLSearchParams({ - ...authParams, - id: album.id, - }), - headers, + describe("that is not starred", () => { + it("should return the track", async () => { + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + rating: { + love: false, + stars: 0, + }, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.track(track.id)); + + expect(result).toEqual({ + ...track, + rating: { love: false, stars: 0 }, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getSong`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbum`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + }); }); }); }); @@ -2434,10 +2590,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getSongXml(track))) + Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, []))) + Promise.resolve(ok(getAlbumJson(artist, album, []))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2476,10 +2632,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getSongXml(track))) + Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, []))) + Promise.resolve(ok(getAlbumJson(artist, album, []))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2520,10 +2676,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getSongXml(track))) + Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, []))) + Promise.resolve(ok(getAlbumJson(artist, album, []))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2565,10 +2721,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getSongXml(track))) + Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, []))) + Promise.resolve(ok(getAlbumJson(artist, album, []))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2606,10 +2762,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getSongXml(track))) + Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, []))) + Promise.resolve(ok(getAlbumJson(artist, album, []))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2659,10 +2815,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getSongXml(track))) + Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, [track]))) + Promise.resolve(ok(getAlbumJson(artist, album, [track]))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2704,10 +2860,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getSongXml(track))) + Promise.resolve(ok(getSongJson(track))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, [track]))) + Promise.resolve(ok(getAlbumJson(artist, album, [track]))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2858,10 +3014,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -2880,7 +3036,7 @@ describe("Subsonic", () => { `${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, count: 50, includeNotPresent: true, @@ -2910,10 +3066,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.reject("BOOOM")); @@ -2960,10 +3116,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse) @@ -2981,7 +3137,7 @@ describe("Subsonic", () => { `${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, }), headers, @@ -2992,7 +3148,7 @@ describe("Subsonic", () => { `${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, count: 50, includeNotPresent: true, @@ -3022,10 +3178,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse) @@ -3046,7 +3202,7 @@ describe("Subsonic", () => { `${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, }), headers, @@ -3057,7 +3213,7 @@ describe("Subsonic", () => { `${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, count: 50, includeNotPresent: true, @@ -3096,10 +3252,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse) @@ -3120,7 +3276,7 @@ describe("Subsonic", () => { `${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, }), headers, @@ -3131,7 +3287,7 @@ describe("Subsonic", () => { `${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, count: 50, includeNotPresent: true, @@ -3172,10 +3328,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.reject("BOOOM")); @@ -3218,10 +3374,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -3235,7 +3391,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, }), headers, @@ -3245,7 +3401,7 @@ describe("Subsonic", () => { `${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, count: 50, includeNotPresent: true, @@ -3287,10 +3443,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -3315,7 +3471,7 @@ describe("Subsonic", () => { `${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, count: 50, includeNotPresent: true, @@ -3365,10 +3521,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -3385,7 +3541,8 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, + f: "json", id: artistId, }), headers, @@ -3395,7 +3552,7 @@ describe("Subsonic", () => { `${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, count: 50, includeNotPresent: true, @@ -3446,10 +3603,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -3463,7 +3620,8 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, + f: "json", id: artistId, }), headers, @@ -3473,7 +3631,7 @@ describe("Subsonic", () => { `${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, count: 50, includeNotPresent: true, @@ -3522,10 +3680,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -3542,7 +3700,7 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, }), headers, @@ -3552,7 +3710,7 @@ describe("Subsonic", () => { `${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, count: 50, includeNotPresent: true, @@ -3603,10 +3761,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) + Promise.resolve(ok(getArtistInfoJson(artist))) ) .mockImplementationOnce(() => Promise.resolve(streamResponse)); @@ -3620,7 +3778,8 @@ describe("Subsonic", () => { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, + f: "json", id: artistId, }), headers, @@ -3630,7 +3789,7 @@ describe("Subsonic", () => { `${url}/rest/getArtistInfo2`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: artistId, count: 50, includeNotPresent: true, @@ -3645,6 +3804,249 @@ describe("Subsonic", () => { }); }); + describe("rate", () => { + const trackId = uuid(); + + const rate = (trackId: string, rating: Rating) => + navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.rate(trackId, rating)); + + const artist = anArtist(); + const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); + + describe("rating a track", () => { + describe("loving a track that isnt already loved", () => { + it("should mark the track as loved", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: false, stars: 0 }, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/star`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + }); + }); + }); + + describe("unloving a track that is loved", () => { + it("should mark the track as loved", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 0 }, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: false, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + }); + }); + }); + + describe("loving a track that is already loved", () => { + it("shouldn't do anything", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 0 }, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledTimes(3); + }); + }); + + describe("rating a track with a different rating", () => { + it("should add the new rating", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: false, stars: 0 }, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: false, stars: 3 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + rating: 3, + }), + headers, + }); + }); + }); + + describe("rating a track with the same rating it already has", () => { + it("shouldn't do anything", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 3 }, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ); + + const result = await rate(trackId, { love: true, stars: 3 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledTimes(3); + }); + }); + + describe("loving and rating a track", () => { + it("should return true", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 3 }, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist, album, []))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: false, stars: 5 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/unstar`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + }); + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/setRating`, { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + rating: 5, + }), + headers, + }); + }); + }); + + describe("invalid star values", () => { + describe("stars of -1", () => { + it("should return false", async () => { + mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + + const result = await rate(trackId, { love: true, stars: -1 }); + expect(result).toEqual(false); + }); + }); + + describe("stars of 6", () => { + it("should return false", async () => { + mockGET.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + + const result = await rate(trackId, { love: true, stars: -1 }); + expect(result).toEqual(false); + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => Promise.resolve(ok(FAILURE))) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(false); + }); + }); + }); + }); + describe("scrobble", () => { describe("when succeeds", () => { it("should return true", async () => { @@ -3664,7 +4066,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id, submission: true, }), @@ -3696,7 +4098,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id, submission: true, }), @@ -3725,7 +4127,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id, submission: false, }), @@ -3757,7 +4159,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/scrobble`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id, submission: false, }), @@ -3775,7 +4177,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(searchResult3({ artists: [artist1] }))) + Promise.resolve(ok(getSearchResult3Json({ artists: [artist1] }))) ); const result = await navidrome @@ -3788,7 +4190,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, artistCount: 20, albumCount: 0, songCount: 0, @@ -3807,7 +4209,9 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(searchResult3({ artists: [artist1, artist2] }))) + Promise.resolve( + ok(getSearchResult3Json({ artists: [artist1, artist2] })) + ) ); const result = await navidrome @@ -3823,7 +4227,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, artistCount: 20, albumCount: 0, songCount: 0, @@ -3839,7 +4243,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(searchResult3({ artists: [] }))) + Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) ); const result = await navidrome @@ -3852,7 +4256,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, artistCount: 20, albumCount: 0, songCount: 0, @@ -3876,7 +4280,9 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(searchResult3({ albums: [{ artist, album }] }))) + Promise.resolve( + ok(getSearchResult3Json({ albums: [{ artist, album }] })) + ) ); const result = await navidrome @@ -3889,7 +4295,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, artistCount: 0, albumCount: 20, songCount: 0, @@ -3919,7 +4325,7 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve( ok( - searchResult3({ + getSearchResult3Json({ albums: [ { artist: artist1, album: album1 }, { artist: artist2, album: album2 }, @@ -3942,7 +4348,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, artistCount: 0, albumCount: 20, songCount: 0, @@ -3958,7 +4364,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(searchResult3({ albums: [] }))) + Promise.resolve(ok(getSearchResult3Json({ albums: [] }))) ); const result = await navidrome @@ -3971,7 +4377,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, artistCount: 0, albumCount: 20, songCount: 0, @@ -4003,11 +4409,11 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(searchResult3({ tracks: [track] }))) + Promise.resolve(ok(getSearchResult3Json({ tracks: [track] }))) ) - .mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track)))) + .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track)))) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album, []))) + Promise.resolve(ok(getAlbumJson(artist, album, []))) ); const result = await navidrome @@ -4020,7 +4426,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, artistCount: 0, albumCount: 0, songCount: 20, @@ -4066,19 +4472,23 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve( ok( - searchResult3({ + getSearchResult3Json({ tracks: [track1, track2], }) ) ) ) - .mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track1)))) - .mockImplementationOnce(() => Promise.resolve(ok(getSongXml(track2)))) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist1, album1, []))) + Promise.resolve(ok(getSongJson(track1))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist2, album2, []))) + Promise.resolve(ok(getSongJson(track2))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(artist2, album2, []))) ); const result = await navidrome @@ -4091,7 +4501,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, artistCount: 0, albumCount: 0, songCount: 20, @@ -4107,7 +4517,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(searchResult3({ tracks: [] }))) + Promise.resolve(ok(getSearchResult3Json({ tracks: [] }))) ); const result = await navidrome @@ -4120,7 +4530,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/search3`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, artistCount: 0, albumCount: 0, songCount: 20, @@ -4141,7 +4551,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getPlayLists([playlist]))) + Promise.resolve(ok(getPlayListsJson([playlist]))) ); const result = await navidrome @@ -4153,7 +4563,7 @@ describe("Subsonic", () => { expect(result).toEqual([playlist]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); }); @@ -4169,7 +4579,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getPlayLists(playlists))) + Promise.resolve(ok(getPlayListsJson(playlists))) ); const result = await navidrome @@ -4181,7 +4591,7 @@ describe("Subsonic", () => { expect(result).toEqual(playlists); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); }); @@ -4192,7 +4602,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getPlayLists([]))) + Promise.resolve(ok(getPlayListsJson([]))) ); const result = await navidrome @@ -4204,7 +4614,7 @@ describe("Subsonic", () => { expect(result).toEqual([]); expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylists`, { - params: asURLSearchParams(authParams), + params: asURLSearchParams(authParamsPlusJson), headers, }); }); @@ -4270,7 +4680,7 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve( ok( - getPlayList({ + getPlayListJson({ id, name, entries: [track1, track2], @@ -4296,7 +4706,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id, }), headers, @@ -4313,7 +4723,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getPlayList(playlist))) + Promise.resolve(ok(getPlayListJson(playlist))) ); const result = await navidrome @@ -4326,7 +4736,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getPlaylist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id: playlist.id, }), headers, @@ -4344,7 +4754,7 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(createPlayList({ id, name }))) + Promise.resolve(ok(createPlayListJson({ id, name }))) ); const result = await navidrome @@ -4357,7 +4767,8 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/createPlaylist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, + f: "json", name, }), headers, @@ -4383,7 +4794,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/deletePlaylist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, id, }), headers, @@ -4411,7 +4822,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, playlistId, songIdToAdd: trackId, }), @@ -4439,7 +4850,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/updatePlaylist`, { params: asURLSearchParams({ - ...authParams, + ...authParamsPlusJson, playlistId, songIndexToRemove: indicies, }), @@ -4473,10 +4884,10 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(similarSongsXml([track1]))) + Promise.resolve(ok(getSimilarSongsJson([track1]))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist1, album1, []))) + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) ); const result = await navidrome @@ -4490,6 +4901,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { params: asURLSearchParams({ ...authParams, + f: "json", id, count: 50, }), @@ -4539,16 +4951,16 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(similarSongsXml([track1, track2, track3]))) + Promise.resolve(ok(getSimilarSongsJson([track1, track2, track3]))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist1, album1, []))) + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist2, album2, []))) + Promise.resolve(ok(getAlbumJson(artist2, album2, []))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist1, album1, []))) + Promise.resolve(ok(getAlbumJson(artist1, album1, []))) ); const result = await navidrome @@ -4562,6 +4974,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { params: asURLSearchParams({ ...authParams, + f: "json", id, count: 50, }), @@ -4574,10 +4987,11 @@ describe("Subsonic", () => { it("should return []", async () => { const id = "idWithNoTracks"; - const xml = similarSongsXml([]); mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(xml))); + .mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([]))) + ); const result = await navidrome .generateToken({ username, password }) @@ -4590,6 +5004,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs2`, { params: asURLSearchParams({ ...authParams, + f: "json", id, count: 50, }), @@ -4642,13 +5057,13 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(topSongsXml([track1]))) + Promise.resolve(ok(getTopSongsJson([track1]))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album1, []))) + Promise.resolve(ok(getAlbumJson(artist, album1, []))) ); const result = await navidrome @@ -4662,6 +5077,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { params: asURLSearchParams({ ...authParams, + f: "json", artist: artistName, count: 50, }), @@ -4705,19 +5121,19 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) .mockImplementationOnce(() => - Promise.resolve(ok(topSongsXml([track1, track2, track3]))) + Promise.resolve(ok(getTopSongsJson([track1, track2, track3]))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album1, []))) + Promise.resolve(ok(getAlbumJson(artist, album1, []))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album2, []))) + Promise.resolve(ok(getAlbumJson(artist, album2, []))) ) .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumXml(artist, album1, []))) + Promise.resolve(ok(getAlbumJson(artist, album1, []))) ); const result = await navidrome @@ -4731,6 +5147,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { params: asURLSearchParams({ ...authParams, + f: "json", artist: artistName, count: 50, }), @@ -4755,9 +5172,11 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) + Promise.resolve(ok(getArtistJson(artist))) ) - .mockImplementationOnce(() => Promise.resolve(ok(topSongsXml([])))); + .mockImplementationOnce(() => + Promise.resolve(ok(getTopSongsJson([]))) + ); const result = await navidrome .generateToken({ username, password }) @@ -4770,6 +5189,7 @@ describe("Subsonic", () => { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, { params: asURLSearchParams({ ...authParams, + f: "json", artist: artistName, count: 50, }), diff --git a/web/icons/Heart-85038.svg b/web/icons/Heart-85038.svg new file mode 100644 index 0000000..2c60ddd --- /dev/null +++ b/web/icons/Heart-85038.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/icons/Heart-85339.svg b/web/icons/Heart-85339.svg new file mode 100644 index 0000000..87ab11f --- /dev/null +++ b/web/icons/Heart-85339.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/icons/Star-16101.svg b/web/icons/Star-16101.svg new file mode 100644 index 0000000..5d0f203 --- /dev/null +++ b/web/icons/Star-16101.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/icons/Star-43879.svg b/web/icons/Star-43879.svg new file mode 100644 index 0000000..acb4ce6 --- /dev/null +++ b/web/icons/Star-43879.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/web/public/love-selected.svg b/web/public/love-selected.svg new file mode 100644 index 0000000..3b46859 --- /dev/null +++ b/web/public/love-selected.svg @@ -0,0 +1,4 @@ + + + diff --git a/web/public/love-unselected.svg b/web/public/love-unselected.svg new file mode 100644 index 0000000..b9e4dfb --- /dev/null +++ b/web/public/love-unselected.svg @@ -0,0 +1,4 @@ + + + diff --git a/web/public/ratingIcons.html b/web/public/ratingIcons.html new file mode 100644 index 0000000..4350290 --- /dev/null +++ b/web/public/ratingIcons.html @@ -0,0 +1,10 @@ + + +
+
+
+
+
+
+ + \ No newline at end of file diff --git a/web/public/star0.svg b/web/public/star0.svg new file mode 100644 index 0000000..31763bc --- /dev/null +++ b/web/public/star0.svg @@ -0,0 +1,5 @@ + + + diff --git a/web/public/star1.svg b/web/public/star1.svg new file mode 100644 index 0000000..79449e2 --- /dev/null +++ b/web/public/star1.svg @@ -0,0 +1,6 @@ + + + + diff --git a/web/public/star2.svg b/web/public/star2.svg new file mode 100644 index 0000000..f7cb5fd --- /dev/null +++ b/web/public/star2.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/web/public/star3.svg b/web/public/star3.svg new file mode 100644 index 0000000..caddbbd --- /dev/null +++ b/web/public/star3.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/web/public/star4.svg b/web/public/star4.svg new file mode 100644 index 0000000..5aed045 --- /dev/null +++ b/web/public/star4.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/web/public/star5.svg b/web/public/star5.svg new file mode 100644 index 0000000..2d22b3b --- /dev/null +++ b/web/public/star5.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/yarn.lock b/yarn.lock index e27839a..bf704d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1349,7 +1349,7 @@ __metadata: languageName: node linkType: hard -"@xmldom/xmldom@npm:^0.7.0, @xmldom/xmldom@npm:^0.7.4": +"@xmldom/xmldom@npm:^0.7.0": version: 0.7.4 resolution: "@xmldom/xmldom@npm:0.7.4" checksum: f807a921fe2c1b4244bb0c79ac6b61f06c8a71c5108017aa022060aa0ffb0c832aa7a704288a9c66888991bf701da8c9148c0775e66b0b3efe8d884153c5729d @@ -1816,7 +1816,6 @@ __metadata: underscore: ^1.13.1 uuid: ^8.3.2 winston: ^3.3.3 - x2js: ^3.4.2 xmldom-ts: ^0.3.1 xpath-ts: ^1.3.13 languageName: unknown @@ -7320,15 +7319,6 @@ typescript@^4.4.2: languageName: node linkType: hard -"x2js@npm:^3.4.2": - version: 3.4.2 - resolution: "x2js@npm:3.4.2" - dependencies: - "@xmldom/xmldom": ^0.7.4 - checksum: 4a77f684b312492f42265aad88c849347831fe17c7c43c66b2f45f3742bd008221c80d0c6875d2a14d63ebcb833130086d7c0d0103674c68c41a9e222e9c05d2 - languageName: node - linkType: hard - "xdg-basedir@npm:^4.0.0": version: 4.0.0 resolution: "xdg-basedir@npm:4.0.0"