Ability to heart and star tracks whilst playing
Ability to heart and star tracks whilst playing
@@ -29,8 +29,7 @@
|
|||||||
"typescript": "^4.4.2",
|
"typescript": "^4.4.2",
|
||||||
"underscore": "^1.13.1",
|
"underscore": "^1.13.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"winston": "^3.3.3",
|
"winston": "^3.3.3"
|
||||||
"x2js": "^3.4.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chai": "^4.2.21",
|
"@types/chai": "^4.2.21",
|
||||||
@@ -54,8 +53,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rm -Rf build node_modules",
|
"clean": "rm -Rf build node_modules",
|
||||||
"build": "tsc",
|
"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",
|
"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_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",
|
"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",
|
"register-dev": "ts-node ./src/register.ts http://$(hostname):4534",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"gitinfo": "git describe --tags > .gitinfo"
|
"gitinfo": "git describe --tags > .gitinfo"
|
||||||
|
|||||||
29
src/i8n.ts
@@ -12,7 +12,7 @@ export type KEY =
|
|||||||
| "playlists"
|
| "playlists"
|
||||||
| "genres"
|
| "genres"
|
||||||
| "random"
|
| "random"
|
||||||
| "starred"
|
| "topRated"
|
||||||
| "recentlyAdded"
|
| "recentlyAdded"
|
||||||
| "recentlyPlayed"
|
| "recentlyPlayed"
|
||||||
| "mostPlayed"
|
| "mostPlayed"
|
||||||
@@ -37,7 +37,14 @@ export type KEY =
|
|||||||
| "invalidLinkCode"
|
| "invalidLinkCode"
|
||||||
| "loginSuccessful"
|
| "loginSuccessful"
|
||||||
| "loginFailed"
|
| "loginFailed"
|
||||||
| "noSonosDevices";
|
| "noSonosDevices"
|
||||||
|
| "favourites"
|
||||||
|
| "LOVE"
|
||||||
|
| "LOVE_SUCCESS"
|
||||||
|
| "STAR"
|
||||||
|
| "UNSTAR"
|
||||||
|
| "STAR_SUCCESS"
|
||||||
|
| "UNSTAR_SUCCESS";
|
||||||
|
|
||||||
const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||||
"en-US": {
|
"en-US": {
|
||||||
@@ -48,7 +55,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
playlists: "Playlists",
|
playlists: "Playlists",
|
||||||
genres: "Genres",
|
genres: "Genres",
|
||||||
random: "Random",
|
random: "Random",
|
||||||
starred: "Starred",
|
topRated: "Top Rated",
|
||||||
recentlyAdded: "Recently added",
|
recentlyAdded: "Recently added",
|
||||||
recentlyPlayed: "Recently played",
|
recentlyPlayed: "Recently played",
|
||||||
mostPlayed: "Most played",
|
mostPlayed: "Most played",
|
||||||
@@ -73,6 +80,13 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
loginSuccessful: "Login successful!",
|
loginSuccessful: "Login successful!",
|
||||||
loginFailed: "Login failed!",
|
loginFailed: "Login failed!",
|
||||||
noSonosDevices: "No sonos devices",
|
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": {
|
"nl-NL": {
|
||||||
AppLinkMessage: "Sonos koppelen aan $BNB_SONOS_SERVICE_NAME",
|
AppLinkMessage: "Sonos koppelen aan $BNB_SONOS_SERVICE_NAME",
|
||||||
@@ -82,7 +96,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
playlists: "Afspeellijsten",
|
playlists: "Afspeellijsten",
|
||||||
genres: "Genres",
|
genres: "Genres",
|
||||||
random: "Willekeurig",
|
random: "Willekeurig",
|
||||||
starred: "Favorieten",
|
topRated: "Best beoordeeld",
|
||||||
recentlyAdded: "Onlangs toegevoegd",
|
recentlyAdded: "Onlangs toegevoegd",
|
||||||
recentlyPlayed: "Onlangs afgespeeld",
|
recentlyPlayed: "Onlangs afgespeeld",
|
||||||
mostPlayed: "Meest afgespeeld",
|
mostPlayed: "Meest afgespeeld",
|
||||||
@@ -107,6 +121,13 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
loginSuccessful: "Inloggen gelukt!",
|
loginSuccessful: "Inloggen gelukt!",
|
||||||
loginFailed: "Inloggen mislukt!",
|
loginFailed: "Inloggen mislukt!",
|
||||||
noSonosDevices: "Geen Sonos-apparaten",
|
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"
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
12
src/icon.ts
@@ -166,7 +166,7 @@ export type ICON =
|
|||||||
| "playlists"
|
| "playlists"
|
||||||
| "genres"
|
| "genres"
|
||||||
| "random"
|
| "random"
|
||||||
| "starred"
|
| "topRated"
|
||||||
| "recentlyAdded"
|
| "recentlyAdded"
|
||||||
| "recentlyPlayed"
|
| "recentlyPlayed"
|
||||||
| "mostPlayed"
|
| "mostPlayed"
|
||||||
@@ -225,7 +225,10 @@ export type ICON =
|
|||||||
| "skywalker"
|
| "skywalker"
|
||||||
| "leia"
|
| "leia"
|
||||||
| "r2d2"
|
| "r2d2"
|
||||||
| "yoda";
|
| "yoda"
|
||||||
|
| "heart"
|
||||||
|
| "star"
|
||||||
|
| "solidStar";
|
||||||
|
|
||||||
const iconFrom = (name: string) =>
|
const iconFrom = (name: string) =>
|
||||||
new SvgIcon(
|
new SvgIcon(
|
||||||
@@ -241,7 +244,7 @@ export const ICONS: Record<ICON, SvgIcon> = {
|
|||||||
playlists: iconFrom("navidrome-playlists.svg"),
|
playlists: iconFrom("navidrome-playlists.svg"),
|
||||||
genres: iconFrom("Theatre-Mask-111172.svg"),
|
genres: iconFrom("Theatre-Mask-111172.svg"),
|
||||||
random: iconFrom("navidrome-random.svg"),
|
random: iconFrom("navidrome-random.svg"),
|
||||||
starred: iconFrom("navidrome-topRated.svg"),
|
topRated: iconFrom("navidrome-topRated.svg"),
|
||||||
recentlyAdded: iconFrom("navidrome-recentlyAdded.svg"),
|
recentlyAdded: iconFrom("navidrome-recentlyAdded.svg"),
|
||||||
recentlyPlayed: iconFrom("navidrome-recentlyPlayed.svg"),
|
recentlyPlayed: iconFrom("navidrome-recentlyPlayed.svg"),
|
||||||
mostPlayed: iconFrom("navidrome-mostPlayed.svg"),
|
mostPlayed: iconFrom("navidrome-mostPlayed.svg"),
|
||||||
@@ -300,6 +303,9 @@ export const ICONS: Record<ICON, SvgIcon> = {
|
|||||||
leia: iconFrom("Princess-Leia-68568.svg"),
|
leia: iconFrom("Princess-Leia-68568.svg"),
|
||||||
r2d2: iconFrom("R2-D2-39423.svg"),
|
r2d2: iconFrom("R2-D2-39423.svg"),
|
||||||
yoda: iconFrom("Yoda-68107.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];
|
export const STAR_WARS = [ICONS.c3po, ICONS.chewy, ICONS.darth, ICONS.skywalker, ICONS.leia, ICONS.r2d2, ICONS.yoda];
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ export type AlbumSummary = {
|
|||||||
genre: Genre | undefined;
|
genre: Genre | undefined;
|
||||||
coverArt: string | undefined;
|
coverArt: string | undefined;
|
||||||
|
|
||||||
artistName: string;
|
artistName: string | undefined;
|
||||||
artistId: string;
|
artistId: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Album = AlbumSummary & {};
|
export type Album = AlbumSummary & {};
|
||||||
@@ -65,6 +65,11 @@ export type Genre = {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Rating = {
|
||||||
|
love: boolean;
|
||||||
|
stars: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type Track = {
|
export type Track = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -75,6 +80,7 @@ export type Track = {
|
|||||||
coverArt: string | undefined;
|
coverArt: string | undefined;
|
||||||
album: AlbumSummary;
|
album: AlbumSummary;
|
||||||
artist: ArtistSummary;
|
artist: ArtistSummary;
|
||||||
|
rating: Rating;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Paging = {
|
export type Paging = {
|
||||||
@@ -177,6 +183,7 @@ export interface MusicLibrary {
|
|||||||
trackId: string;
|
trackId: string;
|
||||||
range: string | undefined;
|
range: string | undefined;
|
||||||
}): Promise<TrackStream>;
|
}): Promise<TrackStream>;
|
||||||
|
rate(trackId: string, rating: Rating): Promise<boolean>;
|
||||||
coverArt(id: string, size?: number): Promise<CoverArt | undefined>;
|
coverArt(id: string, size?: number): Promise<CoverArt | undefined>;
|
||||||
nowPlaying(id: string): Promise<boolean>
|
nowPlaying(id: string): Promise<boolean>
|
||||||
scrobble(id: string): Promise<boolean>
|
scrobble(id: string): Promise<boolean>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as Eta from "eta";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { PassThrough, Transform, TransformCallback } from "stream";
|
import { PassThrough, Transform, TransformCallback } from "stream";
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ import {
|
|||||||
CREATE_REGISTRATION_ROUTE,
|
CREATE_REGISTRATION_ROUTE,
|
||||||
REMOVE_REGISTRATION_ROUTE,
|
REMOVE_REGISTRATION_ROUTE,
|
||||||
sonosifyMimeType,
|
sonosifyMimeType,
|
||||||
|
ratingFromInt,
|
||||||
|
ratingAsInt,
|
||||||
} from "./smapi";
|
} from "./smapi";
|
||||||
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
||||||
import { MusicService, isSuccess } from "./music_service";
|
import { MusicService, isSuccess } from "./music_service";
|
||||||
@@ -107,6 +110,8 @@ function server(
|
|||||||
const accessTokens = serverOpts.accessTokens();
|
const accessTokens = serverOpts.accessTokens();
|
||||||
const clock = serverOpts.clock;
|
const clock = serverOpts.clock;
|
||||||
|
|
||||||
|
const startUpTime = dayjs();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const i8n = makeI8N(service.name);
|
const i8n = makeI8N(service.name);
|
||||||
|
|
||||||
@@ -253,6 +258,28 @@ function server(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get(PRESENTATION_MAP_ROUTE, (_, res) => {
|
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" ?>
|
res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<Presentation>
|
<Presentation>
|
||||||
<PresentationMap type="ArtWorkSizeMap">
|
<PresentationMap type="ArtWorkSizeMap">
|
||||||
@@ -285,6 +312,20 @@ function server(
|
|||||||
</SearchCategories>
|
</SearchCategories>
|
||||||
</Match>
|
</Match>
|
||||||
</PresentationMap>
|
</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>`);
|
</Presentation>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
68
src/smapi.ts
@@ -14,6 +14,7 @@ import {
|
|||||||
Genre,
|
Genre,
|
||||||
MusicService,
|
MusicService,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
Rating,
|
||||||
slice2,
|
slice2,
|
||||||
Track,
|
Track,
|
||||||
} from "./music_service";
|
} 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 = {
|
export type MediaCollection = {
|
||||||
id: string;
|
id: string;
|
||||||
itemType: "collection";
|
itemType: "collection";
|
||||||
@@ -300,6 +307,9 @@ export const track = (bonobUrl: URLBuilder, track: Track) => ({
|
|||||||
genreId: track.album.genre?.id,
|
genreId: track.album.genre?.id,
|
||||||
trackNumber: track.number,
|
trackNumber: track.number,
|
||||||
},
|
},
|
||||||
|
dynamic: {
|
||||||
|
property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }],
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({
|
export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({
|
||||||
@@ -403,7 +413,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
getMediaURIResult: bonobUrl
|
getMediaURIResult: bonobUrl
|
||||||
.append({
|
.append({
|
||||||
pathname: `/stream/${type}/${typeId}`,
|
pathname: `/stream/${type}/${typeId}`,
|
||||||
searchParams: { "bat": accessToken }
|
searchParams: { bat: accessToken },
|
||||||
})
|
})
|
||||||
.href(),
|
.href(),
|
||||||
})),
|
})),
|
||||||
@@ -416,7 +426,10 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
.then(async ({ musicLibrary, accessToken, typeId }) =>
|
.then(async ({ musicLibrary, accessToken, typeId }) =>
|
||||||
musicLibrary.track(typeId!).then((it) => ({
|
musicLibrary.track(typeId!).then((it) => ({
|
||||||
getMediaMetadataResult: track(urlWithToken(accessToken), it),
|
getMediaMetadataResult: track(
|
||||||
|
urlWithToken(accessToken),
|
||||||
|
it
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
search: async (
|
search: async (
|
||||||
@@ -503,9 +516,10 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
case "track":
|
case "track":
|
||||||
return musicLibrary.track(typeId).then((it) => ({
|
return musicLibrary.track(typeId).then((it) => ({
|
||||||
getExtendedMetadataResult: {
|
getExtendedMetadataResult: {
|
||||||
mediaMetadata: {
|
mediaMetadata: track(
|
||||||
...track(urlWithToken(accessToken), it),
|
urlWithToken(accessToken),
|
||||||
},
|
it
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
case "album":
|
case "album":
|
||||||
@@ -580,6 +594,24 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
|
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
|
||||||
itemType: "albumList",
|
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",
|
id: "playlists",
|
||||||
title: lang("playlists"),
|
title: lang("playlists"),
|
||||||
@@ -597,18 +629,6 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||||
itemType: "container",
|
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",
|
id: "recentlyAdded",
|
||||||
title: lang("recentlyAdded"),
|
title: lang("recentlyAdded"),
|
||||||
@@ -689,7 +709,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
type: "random",
|
type: "random",
|
||||||
...paging,
|
...paging,
|
||||||
});
|
});
|
||||||
case "starredAlbums":
|
case "favouriteAlbums":
|
||||||
return albums({
|
return albums({
|
||||||
type: "starred",
|
type: "starred",
|
||||||
...paging,
|
...paging,
|
||||||
@@ -872,6 +892,18 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((_) => ({ removeFromContainerResult: { updateId: "" } })),
|
.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 (
|
setPlayedSeconds: async (
|
||||||
{ id, seconds }: { id: string; seconds: string },
|
{ id, seconds }: { id: string; seconds: string },
|
||||||
_,
|
_,
|
||||||
|
|||||||
24
src/sonos.ts
@@ -24,25 +24,25 @@ export const SONOS_LANG: LANG[] = [
|
|||||||
"zh-CN",
|
"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,
|
// NOTE: manifest requires https for the URL, otherwise you will get an error trying to register
|
||||||
// otherwise you will get an error trying to register
|
|
||||||
export type Capability =
|
export type Capability =
|
||||||
| "search"
|
| "search"
|
||||||
| "trFavorites"
|
| "trFavorites" // Favorites: Adding/Removing Tracks (deprecated)
|
||||||
| "alFavorites"
|
| "alFavorites" // Favorites: Adding/Removing Albums (deprecated)
|
||||||
| "ucPlaylists"
|
| "ucPlaylists" // User Content Playlists
|
||||||
| "extendedMD"
|
| "extendedMD" // Extended Metadata (More Menu, Info & Options)
|
||||||
| "contextHeaders"
|
| "contextHeaders"
|
||||||
| "authorizationHeader"
|
| "authorizationHeader"
|
||||||
| "logging"
|
| "logging" // Playback duration logging at track end (deprecated)
|
||||||
| "manifest";
|
| "manifest";
|
||||||
|
|
||||||
export const BONOB_CAPABILITIES: Capability[] = [
|
export const BONOB_CAPABILITIES: Capability[] = [
|
||||||
"search",
|
"search",
|
||||||
// "trFavorites",
|
|
||||||
// "alFavorites",
|
|
||||||
"ucPlaylists",
|
"ucPlaylists",
|
||||||
"extendedMD",
|
"extendedMD",
|
||||||
"logging",
|
"logging",
|
||||||
@@ -247,9 +247,7 @@ export type Discovery = {
|
|||||||
seedHost?: string;
|
seedHost?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (
|
export default (sonosDiscovery: Discovery = { enabled: true }): Sonos =>
|
||||||
sonosDiscovery: Discovery = { enabled: true }
|
|
||||||
): Sonos =>
|
|
||||||
sonosDiscovery.enabled
|
sonosDiscovery.enabled
|
||||||
? autoDiscoverySonos(sonosDiscovery.seedHost)
|
? autoDiscoverySonos(sonosDiscovery.seedHost)
|
||||||
: SONOS_DISABLED;
|
: SONOS_DISABLED;
|
||||||
|
|||||||
315
src/subsonic.ts
@@ -19,8 +19,8 @@ import {
|
|||||||
Genre,
|
Genre,
|
||||||
Track,
|
Track,
|
||||||
CoverArt,
|
CoverArt,
|
||||||
|
Rating,
|
||||||
} from "./music_service";
|
} from "./music_service";
|
||||||
import X2JS from "x2js";
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import _ from "underscore";
|
import _ from "underscore";
|
||||||
import fse from "fs-extra";
|
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) =>
|
export const validate = (url: string | undefined) =>
|
||||||
url && !isDodgyImage(url) ? url : undefined;
|
url && !isDodgyImage(url) ? url : undefined;
|
||||||
|
|
||||||
export type SubconicEnvelope = {
|
export type SubsonicEnvelope = {
|
||||||
"subsonic-response": SubsonicResponse;
|
"subsonic-response": SubsonicResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SubsonicResponse = {
|
export type SubsonicResponse = {
|
||||||
_status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type album = {
|
export type album = {
|
||||||
_id: string;
|
id: string;
|
||||||
_name: string;
|
name: string;
|
||||||
_genre: string | undefined;
|
artist: string | undefined;
|
||||||
_year: string | undefined;
|
artistId: string | undefined;
|
||||||
_coverArt: string | undefined;
|
coverArt: string | undefined;
|
||||||
_artist: string;
|
genre: string | undefined;
|
||||||
_artistId: string;
|
year: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type artistSummary = {
|
export type artistSummary = {
|
||||||
_id: string;
|
id: string;
|
||||||
_name: string;
|
name: string;
|
||||||
_albumCount: string;
|
albumCount: number;
|
||||||
_artistImageUrl: string | undefined;
|
artistImageUrl: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetArtistsResponse = SubsonicResponse & {
|
export type GetArtistsResponse = SubsonicResponse & {
|
||||||
artists: {
|
artists: {
|
||||||
index: {
|
index: {
|
||||||
artist: artistSummary[];
|
artist: artistSummary[];
|
||||||
_name: string;
|
name: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -101,9 +101,9 @@ export type GetAlbumListResponse = SubsonicResponse & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type genre = {
|
export type genre = {
|
||||||
_songCount: string;
|
songCount: number;
|
||||||
_albumCount: string;
|
albumCount: number;
|
||||||
__text: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetGenresResponse = SubsonicResponse & {
|
export type GetGenresResponse = SubsonicResponse & {
|
||||||
@@ -114,8 +114,8 @@ export type GetGenresResponse = SubsonicResponse & {
|
|||||||
|
|
||||||
export type SubsonicError = SubsonicResponse & {
|
export type SubsonicError = SubsonicResponse & {
|
||||||
error: {
|
error: {
|
||||||
_code: string;
|
code: string;
|
||||||
_message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,22 +145,25 @@ export type GetArtistResponse = SubsonicResponse & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type song = {
|
export type song = {
|
||||||
_id: string;
|
id: string;
|
||||||
_parent: string;
|
parent: string | undefined;
|
||||||
_title: string;
|
title: string;
|
||||||
_album: string;
|
album: string | undefined;
|
||||||
_artist: string;
|
artist: string | undefined;
|
||||||
_track: string | undefined;
|
track: number | undefined;
|
||||||
_genre: string;
|
year: string | undefined;
|
||||||
_coverArt: string | undefined;
|
genre: string | undefined;
|
||||||
_created: "2004-11-08T23:36:11";
|
coverArt: string | undefined;
|
||||||
_duration: string | undefined;
|
created: string | undefined;
|
||||||
_bitRate: "128";
|
duration: number | undefined;
|
||||||
_suffix: "mp3";
|
bitRate: number | undefined;
|
||||||
_contentType: string;
|
suffix: string | undefined;
|
||||||
_albumId: string;
|
contentType: string | undefined;
|
||||||
_artistId: string;
|
albumId: string | undefined;
|
||||||
_type: "music";
|
artistId: string | undefined;
|
||||||
|
type: string | undefined;
|
||||||
|
userRating: number | undefined;
|
||||||
|
starred: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetAlbumResponse = {
|
export type GetAlbumResponse = {
|
||||||
@@ -170,31 +173,15 @@ export type GetAlbumResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type playlist = {
|
export type playlist = {
|
||||||
_id: string;
|
id: string;
|
||||||
_name: 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetPlaylistResponse = {
|
export type GetPlaylistResponse = {
|
||||||
playlist: {
|
playlist: {
|
||||||
_id: string;
|
id: string;
|
||||||
_name: string;
|
name: string;
|
||||||
entry: entry[];
|
entry: song[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,6 +201,12 @@ export type GetSongResponse = {
|
|||||||
song: song;
|
song: song;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetStarredResponse = {
|
||||||
|
starred2: {
|
||||||
|
song: song[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type Search3Response = SubsonicResponse & {
|
export type Search3Response = SubsonicResponse & {
|
||||||
searchResult3: {
|
searchResult3: {
|
||||||
artist: artistSummary[];
|
artist: artistSummary[];
|
||||||
@@ -253,29 +246,33 @@ export const MAX_ALBUM_LIST = 500;
|
|||||||
const maybeAsCoverArt = (coverArt: string | undefined) =>
|
const maybeAsCoverArt = (coverArt: string | undefined) =>
|
||||||
coverArt ? `coverArt:${coverArt}` : undefined;
|
coverArt ? `coverArt:${coverArt}` : undefined;
|
||||||
|
|
||||||
const asTrack = (album: Album, song: song) => ({
|
export const asTrack = (album: Album, song: song): Track => ({
|
||||||
id: song._id,
|
id: song.id,
|
||||||
name: song._title,
|
name: song.title,
|
||||||
mimeType: song._contentType,
|
mimeType: song.contentType!,
|
||||||
duration: parseInt(song._duration || "0"),
|
duration: song.duration || 0,
|
||||||
number: parseInt(song._track || "0"),
|
number: song.track || 0,
|
||||||
genre: maybeAsGenre(song._genre),
|
genre: maybeAsGenre(song.genre),
|
||||||
coverArt: maybeAsCoverArt(song._coverArt),
|
coverArt: maybeAsCoverArt(song.coverArt),
|
||||||
album,
|
album,
|
||||||
artist: {
|
artist: {
|
||||||
id: song._artistId,
|
id: `${song.artistId!}`,
|
||||||
name: song._artist,
|
name: song.artist!,
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
love: song.starred != undefined,
|
||||||
|
stars: (song.userRating && song.userRating <= 5 && song.userRating >= 0 ? song.userRating : 0),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const asAlbum = (album: album) => ({
|
const asAlbum = (album: album) => ({
|
||||||
id: album._id,
|
id: album.id,
|
||||||
name: album._name,
|
name: album.name,
|
||||||
year: album._year,
|
year: album.year,
|
||||||
genre: maybeAsGenre(album._genre),
|
genre: maybeAsGenre(album.genre),
|
||||||
artistId: album._artistId,
|
artistId: album.artistId,
|
||||||
artistName: album._artist,
|
artistName: album.artist,
|
||||||
coverArt: maybeAsCoverArt(album._coverArt),
|
coverArt: maybeAsCoverArt(album.coverArt),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const asGenre = (genreName: string) => ({
|
export const asGenre = (genreName: string) => ({
|
||||||
@@ -318,7 +315,7 @@ export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>;
|
|||||||
|
|
||||||
export const cachingImageFetcher =
|
export const cachingImageFetcher =
|
||||||
(cacheDir: string, delegate: ImageFetcher) =>
|
(cacheDir: string, delegate: ImageFetcher) =>
|
||||||
(url: string): Promise<CoverArt | undefined> => {
|
async (url: string): Promise<CoverArt | undefined> => {
|
||||||
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
|
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
|
||||||
return fse
|
return fse
|
||||||
.readFile(filename)
|
.readFile(filename)
|
||||||
@@ -402,31 +399,11 @@ export class Subsonic implements MusicService {
|
|||||||
path: string,
|
path: string,
|
||||||
q: {} = {}
|
q: {} = {}
|
||||||
): Promise<T> =>
|
): Promise<T> =>
|
||||||
this.get({ username, password }, path, q)
|
this.get({ username, password }, path, { f: "json", ...q })
|
||||||
.then(
|
.then((response) => response.data as SubsonicEnvelope)
|
||||||
(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
|
|
||||||
)
|
|
||||||
.then((json) => json["subsonic-response"])
|
.then((json) => json["subsonic-response"])
|
||||||
.then((json) => {
|
.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;
|
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((it) => (it.artists.index || []).flatMap((it) => it.artist || []))
|
||||||
.then((artists) =>
|
.then((artists) =>
|
||||||
artists.map((artist) => ({
|
artists.map((artist) => ({
|
||||||
id: artist._id,
|
id: `${artist.id}`,
|
||||||
name: artist._name,
|
name: artist.name,
|
||||||
albumCount: Number.parseInt(artist._albumCount),
|
albumCount: artist.albumCount,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -469,9 +446,9 @@ export class Subsonic implements MusicService {
|
|||||||
large: validate(it.artistInfo2.largeImageUrl),
|
large: validate(it.artistInfo2.largeImageUrl),
|
||||||
},
|
},
|
||||||
similarArtist: (it.artistInfo2.similarArtist || []).map((artist) => ({
|
similarArtist: (it.artistInfo2.similarArtist || []).map((artist) => ({
|
||||||
id: artist._id,
|
id: `${artist.id}`,
|
||||||
name: artist._name,
|
name: artist.name,
|
||||||
inLibrary: artist._id != "-1",
|
inLibrary: artist.id != "-1",
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -479,13 +456,13 @@ export class Subsonic implements MusicService {
|
|||||||
this.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", { id })
|
this.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", { id })
|
||||||
.then((it) => it.album)
|
.then((it) => it.album)
|
||||||
.then((album) => ({
|
.then((album) => ({
|
||||||
id: album._id,
|
id: album.id,
|
||||||
name: album._name,
|
name: album.name,
|
||||||
year: album._year,
|
year: album.year,
|
||||||
genre: maybeAsGenre(album._genre),
|
genre: maybeAsGenre(album.genre),
|
||||||
artistId: album._artistId,
|
artistId: album.artistId,
|
||||||
artistName: album._artist,
|
artistName: album.artist,
|
||||||
coverArt: maybeAsCoverArt(album._coverArt),
|
coverArt: maybeAsCoverArt(album.coverArt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
getArtist = (
|
getArtist = (
|
||||||
@@ -497,8 +474,8 @@ export class Subsonic implements MusicService {
|
|||||||
})
|
})
|
||||||
.then((it) => it.artist)
|
.then((it) => it.artist)
|
||||||
.then((it) => ({
|
.then((it) => ({
|
||||||
id: it._id,
|
id: it.id,
|
||||||
name: it._name,
|
name: it.name,
|
||||||
albums: this.toAlbumSummary(it.album || []),
|
albums: this.toAlbumSummary(it.album || []),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -526,20 +503,25 @@ export class Subsonic implements MusicService {
|
|||||||
})
|
})
|
||||||
.then((it) => it.song)
|
.then((it) => it.song)
|
||||||
.then((song) =>
|
.then((song) =>
|
||||||
this.getAlbum(credentials, song._albumId).then((album) =>
|
this.getAlbum(credentials, song.albumId!).then((album) =>
|
||||||
asTrack(album, song)
|
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[] =>
|
toAlbumSummary = (albumList: album[]): AlbumSummary[] =>
|
||||||
albumList.map((album) => ({
|
albumList.map((album) => ({
|
||||||
id: album._id,
|
id: album.id,
|
||||||
name: album._name,
|
name: album.name,
|
||||||
year: album._year,
|
year: album.year,
|
||||||
genre: maybeAsGenre(album._genre),
|
genre: maybeAsGenre(album.genre),
|
||||||
artistId: album._artistId,
|
artistId: album.artistId,
|
||||||
artistName: album._artist,
|
artistName: album.artist,
|
||||||
coverArt: maybeAsCoverArt(album._coverArt),
|
coverArt: maybeAsCoverArt(album.coverArt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
search3 = (credentials: Credentials, q: any) =>
|
search3 = (credentials: Credentials, q: any) =>
|
||||||
@@ -596,8 +578,8 @@ export class Subsonic implements MusicService {
|
|||||||
.then((it) =>
|
.then((it) =>
|
||||||
pipe(
|
pipe(
|
||||||
it.genres.genre || [],
|
it.genres.genre || [],
|
||||||
A.filter((it) => Number.parseInt(it._albumCount) > 0),
|
A.filter((it) => it.albumCount > 0),
|
||||||
A.map((it) => it.__text),
|
A.map((it) => it.value),
|
||||||
A.sort(ordString),
|
A.sort(ordString),
|
||||||
A.map((it) => ({ id: b64Encode(it), name: it }))
|
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))
|
(album.song || []).map((song) => asTrack(asAlbum(album), song))
|
||||||
),
|
),
|
||||||
track: (trackId: string) => subsonic.getTrack(credentials, trackId),
|
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 ({
|
stream: async ({
|
||||||
trackId,
|
trackId,
|
||||||
range,
|
range,
|
||||||
@@ -713,7 +729,7 @@ export class Subsonic implements MusicService {
|
|||||||
},
|
},
|
||||||
scrobble: async (id: string) =>
|
scrobble: async (id: string) =>
|
||||||
subsonic
|
subsonic
|
||||||
.get(credentials, `/rest/scrobble`, {
|
.getJSON(credentials, `/rest/scrobble`, {
|
||||||
id,
|
id,
|
||||||
submission: true,
|
submission: true,
|
||||||
})
|
})
|
||||||
@@ -721,7 +737,7 @@ export class Subsonic implements MusicService {
|
|||||||
.catch(() => false),
|
.catch(() => false),
|
||||||
nowPlaying: async (id: string) =>
|
nowPlaying: async (id: string) =>
|
||||||
subsonic
|
subsonic
|
||||||
.get(credentials, `/rest/scrobble`, {
|
.getJSON(credentials, `/rest/scrobble`, {
|
||||||
id,
|
id,
|
||||||
submission: false,
|
submission: false,
|
||||||
})
|
})
|
||||||
@@ -732,8 +748,8 @@ export class Subsonic implements MusicService {
|
|||||||
.search3(credentials, { query, artistCount: 20 })
|
.search3(credentials, { query, artistCount: 20 })
|
||||||
.then(({ artists }) =>
|
.then(({ artists }) =>
|
||||||
artists.map((artist) => ({
|
artists.map((artist) => ({
|
||||||
id: artist._id,
|
id: artist.id,
|
||||||
name: artist._name,
|
name: artist.name,
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
searchAlbums: async (query: string) =>
|
searchAlbums: async (query: string) =>
|
||||||
@@ -745,7 +761,7 @@ export class Subsonic implements MusicService {
|
|||||||
.search3(credentials, { query, songCount: 20 })
|
.search3(credentials, { query, songCount: 20 })
|
||||||
.then(({ songs }) =>
|
.then(({ songs }) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
songs.map((it) => subsonic.getTrack(credentials, it._id))
|
songs.map((it) => subsonic.getTrack(credentials, it.id))
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
playlists: async () =>
|
playlists: async () =>
|
||||||
@@ -753,7 +769,7 @@ export class Subsonic implements MusicService {
|
|||||||
.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
|
.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
|
||||||
.then((it) => it.playlists.playlist || [])
|
.then((it) => it.playlists.playlist || [])
|
||||||
.then((playlists) =>
|
.then((playlists) =>
|
||||||
playlists.map((it) => ({ id: it._id, name: it._name }))
|
playlists.map((it) => ({ id: it.id, name: it.name }))
|
||||||
),
|
),
|
||||||
playlist: async (id: string) =>
|
playlist: async (id: string) =>
|
||||||
subsonic
|
subsonic
|
||||||
@@ -764,29 +780,22 @@ export class Subsonic implements MusicService {
|
|||||||
.then((playlist) => {
|
.then((playlist) => {
|
||||||
let trackNumber = 1;
|
let trackNumber = 1;
|
||||||
return {
|
return {
|
||||||
id: playlist._id,
|
id: playlist.id,
|
||||||
name: playlist._name,
|
name: playlist.name,
|
||||||
entries: (playlist.entry || []).map((entry) => ({
|
entries: (playlist.entry || []).map((entry) => ({
|
||||||
id: entry._id,
|
...asTrack(
|
||||||
name: entry._title,
|
{
|
||||||
mimeType: entry._contentType,
|
id: entry.albumId!,
|
||||||
duration: parseInt(entry._duration || "0"),
|
name: entry.album!,
|
||||||
|
year: entry.year,
|
||||||
|
genre: maybeAsGenre(entry.genre),
|
||||||
|
artistName: entry.artist,
|
||||||
|
artistId: entry.artistId,
|
||||||
|
coverArt: maybeAsCoverArt(entry.coverArt),
|
||||||
|
},
|
||||||
|
entry
|
||||||
|
),
|
||||||
number: trackNumber++,
|
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,
|
name,
|
||||||
})
|
})
|
||||||
.then((it) => it.playlist)
|
.then((it) => it.playlist)
|
||||||
.then((it) => ({ id: it._id, name: it._name })),
|
.then((it) => ({ id: it.id, name: it.name })),
|
||||||
deletePlaylist: async (id: string) =>
|
deletePlaylist: async (id: string) =>
|
||||||
subsonic
|
subsonic
|
||||||
.getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", {
|
.getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", {
|
||||||
@@ -829,7 +838,7 @@ export class Subsonic implements MusicService {
|
|||||||
Promise.all(
|
Promise.all(
|
||||||
songs.map((song) =>
|
songs.map((song) =>
|
||||||
subsonic
|
subsonic
|
||||||
.getAlbum(credentials, song._albumId)
|
.getAlbum(credentials, song.albumId!)
|
||||||
.then((album) => asTrack(album, song))
|
.then((album) => asTrack(album, song))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -846,7 +855,7 @@ export class Subsonic implements MusicService {
|
|||||||
Promise.all(
|
Promise.all(
|
||||||
songs.map((song) =>
|
songs.map((song) =>
|
||||||
subsonic
|
subsonic
|
||||||
.getAlbum(credentials, song._albumId)
|
.getAlbum(credentials, song.albumId!)
|
||||||
.then((album) => asTrack(album, song))
|
.then((album) => asTrack(album, song))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export function aTrack(fields: Partial<Track> = {}): Track {
|
|||||||
const id = uuid();
|
const id = uuid();
|
||||||
const artist = anArtist();
|
const artist = anArtist();
|
||||||
const genre = fields.genre || randomGenre();
|
const genre = fields.genre || randomGenre();
|
||||||
|
const rating = { love: false, stars: Math.floor(Math.random() * 5) };
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: `Track ${id}`,
|
name: `Track ${id}`,
|
||||||
@@ -163,9 +164,10 @@ export function aTrack(fields: Partial<Track> = {}): Track {
|
|||||||
anAlbum({ artistId: artist.id, artistName: artist.name, genre })
|
anAlbum({ artistId: artist.id, artistName: artist.name, genre })
|
||||||
),
|
),
|
||||||
coverArt: `coverArt:${uuid()}`,
|
coverArt: `coverArt:${uuid()}`,
|
||||||
|
rating,
|
||||||
...fields,
|
...fields,
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export function anAlbum(fields: Partial<Album> = {}): Album {
|
export function anAlbum(fields: Partial<Album> = {}): Album {
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ describe("InMemoryMusicService", () => {
|
|||||||
describe("fetching tracks for an album", () => {
|
describe("fetching tracks for an album", () => {
|
||||||
it("should return only tracks on that album", async () => {
|
it("should return only tracks on that album", async () => {
|
||||||
expect(await musicLibrary.tracks(artist1Album1.id)).toEqual([
|
expect(await musicLibrary.tracks(artist1Album1.id)).toEqual([
|
||||||
track1,
|
{ ...track1, rating: { love: false, stars: 0 } },
|
||||||
track2,
|
{ ...track2, rating: { love: false, stars: 0 } },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -192,7 +192,7 @@ describe("InMemoryMusicService", () => {
|
|||||||
describe("fetching a single track", () => {
|
describe("fetching a single track", () => {
|
||||||
describe("when it exists", () => {
|
describe("when it exists", () => {
|
||||||
it("should return the track", async () => {
|
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 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 artistWithNoAlbums = anArtist({ albums: [] });
|
||||||
|
|
||||||
const allAlbums = [artist1, artist2, artist3, artistWithNoAlbums].flatMap(
|
const allAlbums = [artist1, artist2, artist3, artistWithNoAlbums].flatMap(
|
||||||
@@ -258,7 +261,7 @@ describe("InMemoryMusicService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(albums.total).toEqual(totalAlbumCount);
|
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
|
// cannot really assert the results and they will change every time
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -302,13 +305,11 @@ describe("InMemoryMusicService", () => {
|
|||||||
type: "alphabeticalByName",
|
type: "alphabeticalByName",
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
results:
|
results: _.sortBy(allAlbums, "name").map(albumToAlbumSummary),
|
||||||
_.sortBy(allAlbums, 'name').map(albumToAlbumSummary),
|
|
||||||
total: totalAlbumCount,
|
total: totalAlbumCount,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fetching a page", () => {
|
describe("fetching a page", () => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
albumToAlbumSummary,
|
albumToAlbumSummary,
|
||||||
Track,
|
Track,
|
||||||
Genre,
|
Genre,
|
||||||
|
Rating,
|
||||||
} from "../src/music_service";
|
} from "../src/music_service";
|
||||||
|
|
||||||
export class InMemoryMusicService implements MusicService {
|
export class InMemoryMusicService implements MusicService {
|
||||||
@@ -76,7 +77,9 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
case "alphabeticalByArtist":
|
case "alphabeticalByArtist":
|
||||||
return artist2Album;
|
return artist2Album;
|
||||||
case "alphabeticalByName":
|
case "alphabeticalByName":
|
||||||
return artist2Album.sort((a, b) => a.album.name.localeCompare(b.album.name));
|
return artist2Album.sort((a, b) =>
|
||||||
|
a.album.name.localeCompare(b.album.name)
|
||||||
|
);
|
||||||
case "byGenre":
|
case "byGenre":
|
||||||
return artist2Album.filter(
|
return artist2Album.filter(
|
||||||
(it) => it.album.genre?.id === q.genre
|
(it) => it.album.genre?.id === q.genre
|
||||||
@@ -107,18 +110,21 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
A.map((it) => O.fromNullable(it.genre)),
|
A.map((it) => O.fromNullable(it.genre)),
|
||||||
A.compact,
|
A.compact,
|
||||||
A.uniq(fromEquals((x, y) => x.id === y.id)),
|
A.uniq(fromEquals((x, y) => x.id === y.id)),
|
||||||
A.sort(
|
A.sort(fromCompare<Genre>((x, y) => ordString.compare(x.id, y.id)))
|
||||||
fromCompare<Genre>((x, y) => ordString.compare(x.id, y.id))
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tracks: (albumId: string) =>
|
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) =>
|
track: (trackId: string) =>
|
||||||
pipe(
|
pipe(
|
||||||
this.tracks.find((it) => it.id === trackId),
|
this.tracks.find((it) => it.id === trackId),
|
||||||
O.fromNullable,
|
O.fromNullable,
|
||||||
O.map((it) => Promise.resolve(it)),
|
O.map((it) => Promise.resolve({ ...it, rating: { love: false, stars: 0 } })),
|
||||||
O.getOrElse(() =>
|
O.getOrElse(() =>
|
||||||
Promise.reject(`Failed to find track with id ${trackId}`)
|
Promise.reject(`Failed to find track with id ${trackId}`)
|
||||||
)
|
)
|
||||||
@@ -139,10 +145,14 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
playlists: async () => Promise.resolve([]),
|
playlists: async () => Promise.resolve([]),
|
||||||
playlist: async (id: string) =>
|
playlist: async (id: string) =>
|
||||||
Promise.reject(`No playlist with id ${id}`),
|
Promise.reject(`No playlist with id ${id}`),
|
||||||
createPlaylist: async (_: string) => Promise.reject("Unsupported operation"),
|
createPlaylist: async (_: string) =>
|
||||||
deletePlaylist: async (_: string) => Promise.reject("Unsupported operation"),
|
Promise.reject("Unsupported operation"),
|
||||||
addToPlaylist: async (_: string) => Promise.reject("Unsupported operation"),
|
deletePlaylist: async (_: string) =>
|
||||||
removeFromPlaylist: async (_: string, _2: number[]) => Promise.reject("Unsupported operation"),
|
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([]),
|
similarSongs: async (_: string) => Promise.resolve([]),
|
||||||
topSongs: async (_: string) => Promise.resolve([]),
|
topSongs: async (_: string) => Promise.resolve([]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1668,7 +1668,7 @@ describe("server", () => {
|
|||||||
"playlists",
|
"playlists",
|
||||||
"genres",
|
"genres",
|
||||||
"random",
|
"random",
|
||||||
"starred",
|
"heart",
|
||||||
"recentlyAdded",
|
"recentlyAdded",
|
||||||
"recentlyPlayed",
|
"recentlyPlayed",
|
||||||
"mostPlayed",
|
"mostPlayed",
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ import {
|
|||||||
iconArtURI,
|
iconArtURI,
|
||||||
playlistAlbumArtURL,
|
playlistAlbumArtURL,
|
||||||
sonosifyMimeType,
|
sonosifyMimeType,
|
||||||
|
ratingAsInt,
|
||||||
|
ratingFromInt,
|
||||||
} from "../src/smapi";
|
} from "../src/smapi";
|
||||||
|
|
||||||
|
import { keys as i8nKeys } from '../src/i8n';
|
||||||
import {
|
import {
|
||||||
aService,
|
aService,
|
||||||
getAppLinkMessage,
|
getAppLinkMessage,
|
||||||
@@ -54,6 +57,32 @@ import { iconForGenre } from "../src/icon";
|
|||||||
|
|
||||||
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
|
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", () => {
|
describe("service config", () => {
|
||||||
const bonobWithNoContextPath = url("http://localhost:1234");
|
const bonobWithNoContextPath = url("http://localhost:1234");
|
||||||
const bonobWithContextPath = url("http://localhost:5678/some-context-path");
|
const bonobWithContextPath = url("http://localhost:5678/some-context-path");
|
||||||
@@ -72,7 +101,6 @@ describe("service config", () => {
|
|||||||
pathname: PRESENTATION_MAP_ROUTE,
|
pathname: PRESENTATION_MAP_ROUTE,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(STRINGS_ROUTE, () => {
|
|
||||||
async function fetchStringsXml() {
|
async function fetchStringsXml() {
|
||||||
const res = await request(server).get(stringsUrl.path()).send();
|
const res = await request(server).get(stringsUrl.path()).send();
|
||||||
|
|
||||||
@@ -84,6 +112,7 @@ describe("service config", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe(STRINGS_ROUTE, () => {
|
||||||
it("should return xml for the strings", async () => {
|
it("should return xml for the strings", async () => {
|
||||||
const xml = await fetchStringsXml();
|
const xml = await fetchStringsXml();
|
||||||
|
|
||||||
@@ -120,15 +149,17 @@ describe("service config", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe(PRESENTATION_MAP_ROUTE, () => {
|
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();
|
const res = await request(server).get(presentationUrl.path()).send();
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
|
|
||||||
// removing the sonos xml ns as makes xpath queries with xpath-ts painful
|
// 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"', "")
|
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) =>
|
const imageSizeMap = (size: string) =>
|
||||||
xpath.select(
|
xpath.select(
|
||||||
@@ -142,14 +173,7 @@ describe("service config", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should have an BrowseIconSizeMap for all sizes recommended by sonos", async () => {
|
it("should have an BrowseIconSizeMap for all sizes recommended by sonos", async () => {
|
||||||
const res = await request(server).get(presentationUrl.path()).send();
|
const xml = await presentationMapXml();
|
||||||
|
|
||||||
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 imageSizeMap = (size: string) =>
|
const imageSizeMap = (size: string) =>
|
||||||
xpath.select(
|
xpath.select(
|
||||||
@@ -161,6 +185,64 @@ describe("service config", () => {
|
|||||||
expect(imageSizeMap(size)).toEqual(`/size/${size}`);
|
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" },
|
genre: { id: "genre101", name: "some genre" },
|
||||||
}),
|
}),
|
||||||
artist: anArtist({ name: "great artist", id: uuid() }),
|
artist: anArtist({ name: "great artist", id: uuid() }),
|
||||||
coverArt:"coverArt:887766"
|
coverArt: "coverArt:887766",
|
||||||
|
rating: {
|
||||||
|
love: true,
|
||||||
|
stars: 5
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(track(bonobUrl, someTrack)).toEqual({
|
expect(track(bonobUrl, someTrack)).toEqual({
|
||||||
itemType: "track",
|
itemType: "track",
|
||||||
id: `track:${someTrack.id}`,
|
id: `track:${someTrack.id}`,
|
||||||
mimeType: 'audio/flac',
|
mimeType: "audio/flac",
|
||||||
title: someTrack.name,
|
title: someTrack.name,
|
||||||
|
|
||||||
trackMetadata: {
|
trackMetadata: {
|
||||||
@@ -286,6 +372,14 @@ describe("track", () => {
|
|||||||
genreId: someTrack.album.genre?.id,
|
genreId: someTrack.album.genre?.id,
|
||||||
trackNumber: someTrack.number,
|
trackNumber: someTrack.number,
|
||||||
},
|
},
|
||||||
|
dynamic: {
|
||||||
|
property: [
|
||||||
|
{
|
||||||
|
name: "rating",
|
||||||
|
value: `${ratingAsInt(someTrack.rating)}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -328,7 +422,10 @@ describe("playlistAlbumArtURL", () => {
|
|||||||
it("should return question mark icon", () => {
|
it("should return question mark icon", () => {
|
||||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
const playlist = aPlaylist({
|
const playlist = aPlaylist({
|
||||||
entries: [aTrack({ coverArt: undefined }), aTrack({ coverArt: undefined })],
|
entries: [
|
||||||
|
aTrack({ coverArt: undefined }),
|
||||||
|
aTrack({ coverArt: undefined }),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
@@ -403,7 +500,9 @@ describe("playlistAlbumArtURL", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("defaultAlbumArtURI", () => {
|
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", () => {
|
describe("when there is an album coverArt", () => {
|
||||||
it("should use it in the image url", () => {
|
it("should use it in the image url", () => {
|
||||||
@@ -421,10 +520,7 @@ describe("defaultAlbumArtURI", () => {
|
|||||||
describe("when there is no album coverArt", () => {
|
describe("when there is no album coverArt", () => {
|
||||||
it("should return a vinly icon image", () => {
|
it("should return a vinly icon image", () => {
|
||||||
expect(
|
expect(
|
||||||
defaultAlbumArtURI(
|
defaultAlbumArtURI(bonobUrl, anAlbum({ coverArt: undefined })).href()
|
||||||
bonobUrl,
|
|
||||||
anAlbum({ coverArt: undefined })
|
|
||||||
).href()
|
|
||||||
).toEqual(
|
).toEqual(
|
||||||
"http://bonob.example.com:8080/context/icon/vinyl/size/legacy?search=yes"
|
"http://bonob.example.com:8080/context/icon/vinyl/size/legacy?search=yes"
|
||||||
);
|
);
|
||||||
@@ -473,6 +569,7 @@ describe("api", () => {
|
|||||||
removeFromPlaylist: jest.fn(),
|
removeFromPlaylist: jest.fn(),
|
||||||
scrobble: jest.fn(),
|
scrobble: jest.fn(),
|
||||||
nowPlaying: jest.fn(),
|
nowPlaying: jest.fn(),
|
||||||
|
rate: jest.fn(),
|
||||||
};
|
};
|
||||||
const accessTokens = {
|
const accessTokens = {
|
||||||
mint: jest.fn(),
|
mint: jest.fn(),
|
||||||
@@ -868,6 +965,18 @@ describe("api", () => {
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
|
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
|
||||||
itemType: "albumList",
|
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",
|
id: "playlists",
|
||||||
title: "Playlists",
|
title: "Playlists",
|
||||||
@@ -885,18 +994,6 @@ describe("api", () => {
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||||
itemType: "container",
|
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",
|
id: "recentlyAdded",
|
||||||
title: "Recently added",
|
title: "Recently added",
|
||||||
@@ -955,6 +1052,18 @@ describe("api", () => {
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
|
albumArtURI: iconArtURI(bonobUrl, "albums").href(),
|
||||||
itemType: "albumList",
|
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",
|
id: "playlists",
|
||||||
title: "Afspeellijsten",
|
title: "Afspeellijsten",
|
||||||
@@ -972,18 +1081,6 @@ describe("api", () => {
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||||
itemType: "container",
|
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",
|
id: "recentlyAdded",
|
||||||
title: "Onlangs toegevoegd",
|
title: "Onlangs toegevoegd",
|
||||||
@@ -1568,7 +1665,7 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("asking for starred albums", () => {
|
describe("asking for favourite albums", () => {
|
||||||
const albums = [rock2, rock1, pop2];
|
const albums = [rock2, rock1, pop2];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -1585,7 +1682,7 @@ describe("api", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = await ws.getMetadataAsync({
|
const result = await ws.getMetadataAsync({
|
||||||
id: "starredAlbums",
|
id: "favouriteAlbums",
|
||||||
...paging,
|
...paging,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2325,6 +2422,7 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("asking for a track", () => {
|
describe("asking for a track", () => {
|
||||||
|
describe("that has a love", () => {
|
||||||
it("should return the track", async () => {
|
it("should return the track", async () => {
|
||||||
const track = aTrack();
|
const track = aTrack();
|
||||||
|
|
||||||
@@ -2357,6 +2455,9 @@ describe("api", () => {
|
|||||||
).href(),
|
).href(),
|
||||||
trackNumber: track.number,
|
trackNumber: track.number,
|
||||||
},
|
},
|
||||||
|
dynamic: {
|
||||||
|
property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2364,6 +2465,50 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("asking for an album", () => {
|
describe("asking for an album", () => {
|
||||||
it("should return the album", async () => {
|
it("should return the album", async () => {
|
||||||
const album = anAlbum();
|
const album = anAlbum();
|
||||||
@@ -2471,7 +2616,7 @@ describe("api", () => {
|
|||||||
getMediaURIResult: bonobUrl
|
getMediaURIResult: bonobUrl
|
||||||
.append({
|
.append({
|
||||||
pathname: `/stream/track/${trackId}`,
|
pathname: `/stream/track/${trackId}`,
|
||||||
searchParams: { "bat": accessToken }
|
searchParams: { bat: accessToken },
|
||||||
})
|
})
|
||||||
.href(),
|
.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", () => {
|
describe("setPlayedSeconds", () => {
|
||||||
let ws: Client;
|
let ws: Client;
|
||||||
|
|
||||||
@@ -2812,7 +3015,7 @@ describe("api", () => {
|
|||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
secondsPlayed: number;
|
secondsPlayed: number;
|
||||||
shouldMarkNowPlaying: boolean,
|
shouldMarkNowPlaying: boolean;
|
||||||
}) {
|
}) {
|
||||||
it("should scrobble", async () => {
|
it("should scrobble", async () => {
|
||||||
musicLibrary.scrobble.mockResolvedValue(true);
|
musicLibrary.scrobble.mockResolvedValue(true);
|
||||||
@@ -2827,7 +3030,7 @@ describe("api", () => {
|
|||||||
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
|
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
|
||||||
expect(musicLibrary.track).toHaveBeenCalledWith(trackId);
|
expect(musicLibrary.track).toHaveBeenCalledWith(trackId);
|
||||||
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
|
expect(musicLibrary.scrobble).toHaveBeenCalledWith(trackId);
|
||||||
if(shouldMarkNowPlaying) {
|
if (shouldMarkNowPlaying) {
|
||||||
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
|
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
|
||||||
} else {
|
} else {
|
||||||
expect(musicLibrary.nowPlaying).not.toHaveBeenCalled();
|
expect(musicLibrary.nowPlaying).not.toHaveBeenCalled();
|
||||||
@@ -2842,7 +3045,7 @@ describe("api", () => {
|
|||||||
}: {
|
}: {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
secondsPlayed: number;
|
secondsPlayed: number;
|
||||||
shouldMarkNowPlaying: boolean,
|
shouldMarkNowPlaying: boolean;
|
||||||
}) {
|
}) {
|
||||||
it("should scrobble", async () => {
|
it("should scrobble", async () => {
|
||||||
const result = await ws.setPlayedSecondsAsync({
|
const result = await ws.setPlayedSecondsAsync({
|
||||||
@@ -2855,7 +3058,7 @@ describe("api", () => {
|
|||||||
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
|
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
|
||||||
expect(musicLibrary.track).toHaveBeenCalledWith(trackId);
|
expect(musicLibrary.track).toHaveBeenCalledWith(trackId);
|
||||||
expect(musicLibrary.scrobble).not.toHaveBeenCalled();
|
expect(musicLibrary.scrobble).not.toHaveBeenCalled();
|
||||||
if(shouldMarkNowPlaying) {
|
if (shouldMarkNowPlaying) {
|
||||||
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
|
expect(musicLibrary.nowPlaying).toHaveBeenCalledWith(trackId);
|
||||||
} else {
|
} else {
|
||||||
expect(musicLibrary.nowPlaying).not.toHaveBeenCalled();
|
expect(musicLibrary.nowPlaying).not.toHaveBeenCalled();
|
||||||
@@ -2871,23 +3074,43 @@ describe("api", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("when the seconds played is 30 seconds", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
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", () => {
|
describe("when the seconds played is 0 seconds", () => {
|
||||||
itShouldNotScroble({ trackId, secondsPlayed: 0, shouldMarkNowPlaying: false });
|
itShouldNotScroble({
|
||||||
|
trackId,
|
||||||
|
secondsPlayed: 0,
|
||||||
|
shouldMarkNowPlaying: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
3
web/icons/Heart-85038.svg
Normal 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 |
3
web/icons/Heart-85339.svg
Normal 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
@@ -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
@@ -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 |
4
web/public/love-selected.svg
Normal 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 |
4
web/public/love-unselected.svg
Normal 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 |
10
web/public/ratingIcons.html
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 |
12
yarn.lock
@@ -1349,7 +1349,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@xmldom/xmldom@npm:^0.7.0, @xmldom/xmldom@npm:^0.7.4":
|
"@xmldom/xmldom@npm:^0.7.0":
|
||||||
version: 0.7.4
|
version: 0.7.4
|
||||||
resolution: "@xmldom/xmldom@npm:0.7.4"
|
resolution: "@xmldom/xmldom@npm:0.7.4"
|
||||||
checksum: f807a921fe2c1b4244bb0c79ac6b61f06c8a71c5108017aa022060aa0ffb0c832aa7a704288a9c66888991bf701da8c9148c0775e66b0b3efe8d884153c5729d
|
checksum: f807a921fe2c1b4244bb0c79ac6b61f06c8a71c5108017aa022060aa0ffb0c832aa7a704288a9c66888991bf701da8c9148c0775e66b0b3efe8d884153c5729d
|
||||||
@@ -1816,7 +1816,6 @@ __metadata:
|
|||||||
underscore: ^1.13.1
|
underscore: ^1.13.1
|
||||||
uuid: ^8.3.2
|
uuid: ^8.3.2
|
||||||
winston: ^3.3.3
|
winston: ^3.3.3
|
||||||
x2js: ^3.4.2
|
|
||||||
xmldom-ts: ^0.3.1
|
xmldom-ts: ^0.3.1
|
||||||
xpath-ts: ^1.3.13
|
xpath-ts: ^1.3.13
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
@@ -7320,15 +7319,6 @@ typescript@^4.4.2:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"xdg-basedir@npm:^4.0.0":
|
||||||
version: 4.0.0
|
version: 4.0.0
|
||||||
resolution: "xdg-basedir@npm:4.0.0"
|
resolution: "xdg-basedir@npm:4.0.0"
|
||||||
|
|||||||