Ability to heart and star tracks whilst playing

Ability to heart and star tracks whilst playing
This commit is contained in:
Simon J
2021-10-07 15:57:09 +11:00
committed by GitHub
parent a02b8c1ecd
commit 8f3d2bddf7
28 changed files with 1739 additions and 861 deletions

View File

@@ -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"

View File

@@ -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<SUPPORTED_LANG, Record<KEY, string>> = {
"en-US": {
@@ -48,7 +55,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
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<SUPPORTED_LANG, Record<KEY, string>> = {
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<SUPPORTED_LANG, Record<KEY, string>> = {
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<SUPPORTED_LANG, Record<KEY, string>> = {
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"
},
};

View File

@@ -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<ICON, SvgIcon> = {
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<ICON, SvgIcon> = {
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];

View File

@@ -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<TrackStream>;
rate(trackId: string, rating: Rating): Promise<boolean>;
coverArt(id: string, size?: number): Promise<CoverArt | undefined>;
nowPlaying(id: string): Promise<boolean>
scrobble(id: string): Promise<boolean>

View File

@@ -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 `<Match propname="rating" value="${value}">
<Ratings>
<Rating Id="${ratingAsInt(nextLove)}" AutoSkip="NEVER" OnSuccessStringId="LOVE_SUCCESS" StringId="LOVE">
<Icon Controller="universal" LastModified="${LastModified}" Uri="${loveRatingIcon}" />
</Rating>
<Rating Id="${-ratingAsInt(nextStar)}" AutoSkip="NEVER" OnSuccessStringId="STAR_SUCCESS" StringId="STAR">
<Icon Controller="universal" LastModified="${LastModified}" Uri="${starsRatingIcon}" />
</Rating>
</Ratings>
</Match>`
}
res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?>
<Presentation>
<PresentationMap type="ArtWorkSizeMap">
@@ -285,6 +312,20 @@ function server(
</SearchCategories>
</Match>
</PresentationMap>
<PresentationMap type="NowPlayingRatings" trackEnabled="true" programEnabled="false">
${nowPlayingRatingsMatch(100)}
${nowPlayingRatingsMatch(101)}
${nowPlayingRatingsMatch(110)}
${nowPlayingRatingsMatch(111)}
${nowPlayingRatingsMatch(120)}
${nowPlayingRatingsMatch(121)}
${nowPlayingRatingsMatch(130)}
${nowPlayingRatingsMatch(131)}
${nowPlayingRatingsMatch(140)}
${nowPlayingRatingsMatch(141)}
${nowPlayingRatingsMatch(150)}
${nowPlayingRatingsMatch(151)}
</PresentationMap>
</Presentation>`);
});

View File

@@ -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 },
_,

View File

@@ -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;

View File

@@ -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<CoverArt | undefined>;
export const cachingImageFetcher =
(cacheDir: string, delegate: ImageFetcher) =>
(url: string): Promise<CoverArt | undefined> => {
async (url: string): Promise<CoverArt | undefined> => {
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<T> =>
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<GetAlbumResponse>(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<GetStarredResponse>(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<GetPlaylistsResponse>(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<GetPlaylistResponse>(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))
)
)

View File

@@ -151,6 +151,7 @@ export function aTrack(fields: Partial<Track> = {}): 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> = {}): Track {
anAlbum({ artistId: artist.id, artistName: artist.name, genre })
),
coverArt: `coverArt:${uuid()}`,
rating,
...fields,
};
}
};
export function anAlbum(fields: Partial<Album> = {}): Album {
const id = uuid();

View File

@@ -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", () => {

View File

@@ -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<Genre>((x, y) => ordString.compare(x.id, y.id))
)
A.sort(fromCompare<Genre>((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([]),
});

View File

@@ -1668,7 +1668,7 @@ describe("server", () => {
"playlists",
"genres",
"random",
"starred",
"heart",
"recentlyAdded",
"recentlyPlayed",
"mostPlayed",

View File

@@ -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,
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M16.5,3C13.605,3,12,5.09,12,5.09S10.395,3,7.5,3C4.462,3,2,5.462,2,8.5c0,4.171,4.912,8.213,6.281,9.49C9.858,19.46,12,21.35,12,21.35s2.142-1.89,3.719-3.36C17.088,16.713,22,12.671,22,8.5C22,5.462,19.538,3,16.5,3z M14.811,16.11c-0.177,0.16-0.331,0.299-0.456,0.416c-0.751,0.7-1.639,1.503-2.355,2.145c-0.716-0.642-1.605-1.446-2.355-2.145c-0.126-0.117-0.28-0.257-0.456-0.416C7.769,14.827,4,11.419,4,8.5C4,6.57,5.57,5,7.5,5c1.827,0,2.886,1.275,2.914,1.308L12,8l1.586-1.692C13.596,6.295,14.673,5,16.5,5C18.43,5,20,6.57,20,8.5C20,11.419,16.231,14.827,14.811,16.11z"/>
</svg>

After

Width:  |  Height:  |  Size: 638 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M16.5,3C13.605,3,12,5.09,12,5.09S10.395,3,7.5,3C4.462,3,2,5.462,2,8.5c0,4.171,4.912,8.213,6.281,9.49C9.858,19.46,12,21.35,12,21.35s2.142-1.89,3.719-3.36C17.088,16.713,22,12.671,22,8.5C22,5.462,19.538,3,16.5,3z"/>
</svg>

After

Width:  |  Height:  |  Size: 293 B

3
web/icons/Star-16101.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<path fill="none" stroke="#000" stroke-miterlimit="10" stroke-width="2" d="M16 4.587L19.486 12.407 28 13.306 21.64 19.037 23.416 27.413 16 23.135 8.584 27.413 10.36 19.037 4 13.306 12.514 12.407z"/>
</svg>

After

Width:  |  Height:  |  Size: 270 B

3
web/icons/Star-43879.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="none" stroke="#000" stroke-miterlimit="10" d="M8 2.25L9.701 6.283 13.875 6.738 10.753 9.686 11.631 14 8 11.788 4.369 14 5.247 9.686 2.125 6.738 6.299 6.283z"/>
</svg>

After

Width:  |  Height:  |  Size: 241 B

View File

@@ -0,0 +1,4 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M26.4287 14.0097C25.1137 13.9677 23.7987 14.4057 22.7378 15.3397L22.2507 15.7707L21.7618 15.3377C19.6558 13.4727 16.4607 13.5687 14.4717 15.5577L14.4647 15.5657C12.5117 17.5177 12.5117 20.6837 14.4647 22.6367L21.8897 30.0607C22.0847 30.2567 22.4018 30.2567 22.5968 30.0607L29.8757 22.7817C31.8717 20.7867 31.9697 17.4207 29.9277 15.4747C28.9507 14.5427 27.6967 14.0437 26.4287 14.0097Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 518 B

View File

@@ -0,0 +1,4 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M28.5217 21.3077L22.2437 27.5867L15.8788 21.2227C14.5428 19.8857 14.7378 17.5727 16.4758 16.5097C17.7548 15.7267 19.4187 15.9657 20.5557 16.9447L20.7367 17.0997L21.9117 18.1417C22.1007 18.3097 22.3857 18.3097 22.5747 18.1417L23.7498 17.0997L24.0597 16.8307C25.4047 15.6457 27.4587 15.7457 28.6877 17.0637C29.8018 18.2567 29.6757 20.1537 28.5217 21.3077ZM26.4287 14.0097C25.1137 13.9677 23.7987 14.4057 22.7378 15.3397L22.2507 15.7707L21.7618 15.3377C19.6558 13.4727 16.4607 13.5687 14.4717 15.5577L14.4647 15.5657C12.5117 17.5177 12.5117 20.6837 14.4647 22.6367L21.8897 30.0607C22.0847 30.2567 22.4018 30.2567 22.5968 30.0607L29.8757 22.7817C31.8717 20.7867 31.9697 17.4207 29.9277 15.4747C28.9507 14.5427 27.6967 14.0437 26.4287 14.0097Z" fill="white" fill-opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 890 B

View File

@@ -0,0 +1,10 @@
<html>
<body style="background-color: black;">
<img src="star0.svg" width="80px"><br>
<img src="star1.svg" width="80px"><br>
<img src="star2.svg" width="80px"><br>
<img src="star3.svg" width="80px"><br>
<img src="star4.svg" width="80px"><br>
<img src="star5.svg" width="80px"><br>
</body>
</html>

5
web/public/star0.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg version="1.1" baseProfile="basic" id="_x38_8"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 44 44" xml:space="preserve">
<path id="star" fill="#FFFFFF" d="M28,31l-6-4.3L16,31l2.6-6.8l-5.1-4.3h6l2.5-6.9l2.6,6.9h6l-5.1,4.3L28,31z"/>
</svg>

After

Width:  |  Height:  |  Size: 312 B

6
web/public/star1.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg version="1.1" baseProfile="basic" id="_x38_8"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 44 44" xml:space="preserve">
<path id="star" fill="#FBB040" d="M28,31l-6-4.3L16,31l2.6-6.8l-5.1-4.3h6l2.5-6.9l2.6,6.9h6l-5.1,4.3L28,31z"/>
<circle cx="22" cy="32" r="2" fill="#FBB040"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

7
web/public/star2.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg version="1.1" baseProfile="basic" id="_x38_8"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 44 44" xml:space="preserve">
<path id="star" fill="#FBB040" d="M28,31l-6-4.3L16,31l2.6-6.8l-5.1-4.3h6l2.5-6.9l2.6,6.9h6l-5.1,4.3L28,31z"/>
<circle cx="22" cy="32" r="2" fill="#FBB040"/>
<circle cx="14" cy="26" r="2" fill="#FBB040"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

8
web/public/star3.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg version="1.1" baseProfile="basic" id="_x38_8"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 44 44" xml:space="preserve">
<path id="star" fill="#FBB040" d="M28,31l-6-4.3L16,31l2.6-6.8l-5.1-4.3h6l2.5-6.9l2.6,6.9h6l-5.1,4.3L28,31z"/>
<circle cx="22" cy="32" r="2" fill="#FBB040"/>
<circle cx="14" cy="26" r="2" fill="#FBB040"/>
<circle cx="17" cy="16" r="2" fill="#FBB040"/>
</svg>

After

Width:  |  Height:  |  Size: 459 B

9
web/public/star4.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg version="1.1" baseProfile="basic" id="_x38_8"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 44 44" xml:space="preserve">
<path id="star" fill="#FBB040" d="M28,31l-6-4.3L16,31l2.6-6.8l-5.1-4.3h6l2.5-6.9l2.6,6.9h6l-5.1,4.3L28,31z"/>
<circle cx="22" cy="32" r="2" fill="#FBB040"/>
<circle cx="14" cy="26" r="2" fill="#FBB040"/>
<circle cx="17" cy="16" r="2" fill="#FBB040"/>
<circle cx="27" cy="16" r="2" fill="#FBB040"/>
</svg>

After

Width:  |  Height:  |  Size: 508 B

10
web/public/star5.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg version="1.1" baseProfile="basic" id="_x38_8"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 44 44" xml:space="preserve">
<path id="star" fill="#FBB040" d="M28,31l-6-4.3L16,31l2.6-6.8l-5.1-4.3h6l2.5-6.9l2.6,6.9h6l-5.1,4.3L28,31z"/>
<circle cx="22" cy="32" r="2" fill="#FBB040"/>
<circle cx="14" cy="26" r="2" fill="#FBB040"/>
<circle cx="17" cy="16" r="2" fill="#FBB040"/>
<circle cx="27" cy="16" r="2" fill="#FBB040"/>
<circle cx="30" cy="26" r="2" fill="#FBB040"/>
</svg>

After

Width:  |  Height:  |  Size: 557 B

View File

@@ -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"