Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1010df803 | ||
|
|
cc95beb4f2 | ||
|
|
6116975d7a | ||
|
|
8f3d2bddf7 | ||
|
|
a02b8c1ecd | ||
|
|
effb02f46e | ||
|
|
d7a7747fab |
22
README.md
@@ -9,21 +9,19 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
|
||||
## Features
|
||||
|
||||
- Integrates with Subsonic API clones (Navidrome, Gonic)
|
||||
- Browse by Artist, Albums, Genres, Playlist, Random Albums, Starred Albums, Recently Added Albums, Recently Played Albums, Most Played Albums
|
||||
- Artist Art
|
||||
- Album Art
|
||||
- Browse by Artist, Albums, Random, Favourites, Top Rated, Playlist, Genres, Recently Added Albums, Recently Played Albums, Most Played Albums
|
||||
- Artist & Album Art
|
||||
- View Related Artists via Artist -> '...' -> Menu -> Related Arists
|
||||
- Now playing & Track Scrobbling
|
||||
- Search by Album, Artist, Track
|
||||
- Playlist editing through sonos app.
|
||||
- Marking of songs as favourites and with ratings through the sonos app.
|
||||
- Localization (only en-US & nl-NL supported currently, require translations for other languages). [Sonos localization and supported languages](https://developer.sonos.com/build/content-service-add-features/strings-and-localization/)
|
||||
- Auto discovery of sonos devices
|
||||
- Discovery of sonos devices using seed IP address
|
||||
- Auto register bonob service with sonos system
|
||||
- Auto registration with sonos on start
|
||||
- Multiple registrations within a single household.
|
||||
- Transcoding support for flacs using a specific player for the flac mimeType bonob/sonos
|
||||
- Ability to search by Album, Artist, Track
|
||||
- Ability to play a playlist
|
||||
- Ability to add/remove playlists
|
||||
- Ability to add/remove tracks from a playlist
|
||||
- Localization (only en-US & nl-NL supported currently, require translations for other languages). [Sonos localization and supported languages](https://developer.sonos.com/build/content-service-add-features/strings-and-localization/)
|
||||
|
||||
## Running
|
||||
|
||||
@@ -150,6 +148,7 @@ BNB_SONOS_SERVICE_NAME | bonob | service name for sonos
|
||||
BNB_SONOS_SERVICE_ID | 246 | service id for sonos
|
||||
BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone
|
||||
BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.
|
||||
BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images as are sourced externally. ie. Navidrome provides spotify URLs
|
||||
BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s
|
||||
BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing
|
||||
BNB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html)
|
||||
@@ -158,7 +157,7 @@ BNB_ICON_BACKGROUND_COLOR | undefined | Icon background color in sonos app, must
|
||||
## Initialising service within sonos app
|
||||
|
||||
- Configure bonob, make sure to set BNB_URL. **bonob must be accessible from your sonos devices on BNB_URL, otherwise it will fail to initialise within the sonos app, so make sure you test this in your browser by putting BNB_URL in the address bar and seeing the bonob information page**
|
||||
- Start bonob,
|
||||
- Start bonob
|
||||
- Open sonos app on your device
|
||||
- Settings -> Services & Voice -> + Add a Service
|
||||
- Select your Music Service, default name is 'bonob', can be overriden with configuration BNB_SONOS_SERVICE_NAME
|
||||
@@ -218,6 +217,3 @@ ffmpeg -i %s -af aresample=resampler=soxr:out_sample_fmt=s16:out_sample_rate=480
|
||||
|
||||
- Icons courtesy of: [Navidrome](https://www.navidrome.org/), [Vectornator](https://www.vectornator.io/icons), and @jicho
|
||||
|
||||
## TODO
|
||||
|
||||
- Artist Radio
|
||||
|
||||
11
package.json
@@ -8,6 +8,7 @@
|
||||
"dependencies": {
|
||||
"@svrooij/sonos": "^2.4.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/morgan": "^1.9.3",
|
||||
"@types/node": "^16.7.13",
|
||||
"@types/sharp": "^0.28.6",
|
||||
@@ -18,6 +19,7 @@
|
||||
"eta": "^1.12.3",
|
||||
"express": "^4.17.1",
|
||||
"fp-ts": "^2.11.1",
|
||||
"fs-extra": "^10.0.0",
|
||||
"libxmljs2": "^0.28.0",
|
||||
"morgan": "^1.10.0",
|
||||
"node-html-parser": "^4.1.4",
|
||||
@@ -27,20 +29,21 @@
|
||||
"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",
|
||||
"@types/jest": "^27.0.1",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/tmp": "^0.2.1",
|
||||
"chai": "^4.3.4",
|
||||
"get-port": "^5.1.1",
|
||||
"image-js": "^0.33.0",
|
||||
"jest": "^27.1.0",
|
||||
"nodemon": "^2.0.12",
|
||||
"supertest": "^6.1.6",
|
||||
"tmp": "^0.2.1",
|
||||
"ts-jest": "^27.0.5",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"ts-node": "^10.2.1",
|
||||
@@ -50,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"
|
||||
|
||||
@@ -2059,7 +2059,7 @@
|
||||
|
||||
<wsdl:service name="Sonos">
|
||||
<wsdl:port name="SonosSoap" binding="tns:SonosSoap">
|
||||
<soap:address location="http://moapi.sonos.com/Test/TestService.php"/>
|
||||
<soap:address location="/about"/>
|
||||
</wsdl:port>
|
||||
</wsdl:service>
|
||||
|
||||
|
||||
33
src/app.ts
@@ -2,7 +2,13 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import server from "./server";
|
||||
import logger from "./logger";
|
||||
import { appendMimeTypeToClientFor, DEFAULT, Subsonic } from "./subsonic";
|
||||
import {
|
||||
appendMimeTypeToClientFor,
|
||||
axiosImageFetcher,
|
||||
cachingImageFetcher,
|
||||
DEFAULT,
|
||||
Subsonic,
|
||||
} from "./subsonic";
|
||||
import encryption from "./encryption";
|
||||
import { InMemoryAccessTokens, sha256 } from "./access_tokens";
|
||||
import { InMemoryLinkCodes } from "./link_codes";
|
||||
@@ -28,10 +34,15 @@ const streamUserAgent = config.subsonic.customClientsFor
|
||||
? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(","))
|
||||
: DEFAULT;
|
||||
|
||||
const artistImageFetcher = config.subsonic.artistImageCache
|
||||
? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher)
|
||||
: axiosImageFetcher;
|
||||
|
||||
const subsonic = new Subsonic(
|
||||
config.subsonic.url,
|
||||
encryption(config.secret),
|
||||
streamUserAgent
|
||||
streamUserAgent,
|
||||
artistImageFetcher
|
||||
);
|
||||
|
||||
const featureFlagAwareMusicService: MusicService = {
|
||||
@@ -60,7 +71,9 @@ const featureFlagAwareMusicService: MusicService = {
|
||||
|
||||
export const GIT_INFO = path.join(__dirname, "..", ".gitinfo");
|
||||
|
||||
const version = fs.existsSync(GIT_INFO) ? fs.readFileSync(GIT_INFO).toString().trim() : "v??"
|
||||
const version = fs.existsSync(GIT_INFO)
|
||||
? fs.readFileSync(GIT_INFO).toString().trim()
|
||||
: "v??";
|
||||
|
||||
const app = server(
|
||||
sonosSystem,
|
||||
@@ -74,7 +87,7 @@ const app = server(
|
||||
iconColors: config.icons,
|
||||
applyContextPath: true,
|
||||
logRequests: true,
|
||||
version
|
||||
version,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -90,12 +103,12 @@ if (config.sonos.autoRegister) {
|
||||
);
|
||||
}
|
||||
});
|
||||
} else if(config.sonos.discovery.enabled) {
|
||||
sonosSystem.devices().then(devices => {
|
||||
devices.forEach(d => {
|
||||
logger.info(`Found device ${d.name}(${d.group}) @ ${d.ip}:${d.port}`)
|
||||
})
|
||||
})
|
||||
} else if (config.sonos.discovery.enabled) {
|
||||
sonosSystem.devices().then((devices) => {
|
||||
devices.forEach((d) => {
|
||||
logger.info(`Found device ${d.name}(${d.group}) @ ${d.ip}:${d.port}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function () {
|
||||
subsonic: {
|
||||
url: bnbEnvVar("SUBSONIC_URL", { legacy: ["BONOB_NAVIDROME_URL"], default: `http://${hostname()}:4533` })!,
|
||||
customClientsFor: bnbEnvVar("SUBSONIC_CUSTOM_CLIENTS", { legacy: ["BONOB_NAVIDROME_CUSTOM_CLIENTS"] }),
|
||||
artistImageCache: bnbEnvVar("SUBSONIC_ARTIST_IMAGE_CACHE"),
|
||||
},
|
||||
scrobbleTracks: bnbEnvVar("SCROBBLE_TRACKS", { default: "true" }) == "true",
|
||||
reportNowPlaying:
|
||||
|
||||
29
src/i8n.ts
@@ -12,7 +12,7 @@ export type KEY =
|
||||
| "playlists"
|
||||
| "genres"
|
||||
| "random"
|
||||
| "starred"
|
||||
| "topRated"
|
||||
| "recentlyAdded"
|
||||
| "recentlyPlayed"
|
||||
| "mostPlayed"
|
||||
@@ -37,7 +37,14 @@ export type KEY =
|
||||
| "invalidLinkCode"
|
||||
| "loginSuccessful"
|
||||
| "loginFailed"
|
||||
| "noSonosDevices";
|
||||
| "noSonosDevices"
|
||||
| "favourites"
|
||||
| "LOVE"
|
||||
| "LOVE_SUCCESS"
|
||||
| "STAR"
|
||||
| "UNSTAR"
|
||||
| "STAR_SUCCESS"
|
||||
| "UNSTAR_SUCCESS";
|
||||
|
||||
const translations: Record<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",
|
||||
UNSTAR: "Un-star",
|
||||
STAR_SUCCESS: "Track starred",
|
||||
UNSTAR_SUCCESS: "Track un-starred",
|
||||
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 ",
|
||||
UNSTAR: "Een ster",
|
||||
STAR_SUCCESS: "Nummer met ster",
|
||||
UNSTAR_SUCCESS: "Track zonder ster",
|
||||
LOVE: "Liefde",
|
||||
LOVE_SUCCESS: "Volg geliefd"
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
12
src/icon.ts
@@ -166,7 +166,7 @@ export type ICON =
|
||||
| "playlists"
|
||||
| "genres"
|
||||
| "random"
|
||||
| "starred"
|
||||
| "topRated"
|
||||
| "recentlyAdded"
|
||||
| "recentlyPlayed"
|
||||
| "mostPlayed"
|
||||
@@ -225,7 +225,10 @@ export type ICON =
|
||||
| "skywalker"
|
||||
| "leia"
|
||||
| "r2d2"
|
||||
| "yoda";
|
||||
| "yoda"
|
||||
| "heart"
|
||||
| "star"
|
||||
| "solidStar";
|
||||
|
||||
const iconFrom = (name: string) =>
|
||||
new SvgIcon(
|
||||
@@ -241,7 +244,7 @@ export const ICONS: Record<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];
|
||||
|
||||
@@ -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 = {
|
||||
@@ -101,7 +107,7 @@ export const asResult = <T>([results, total]: [T[], number]) => ({
|
||||
|
||||
export type ArtistQuery = Paging;
|
||||
|
||||
export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'random' | 'recent' | 'frequent' | 'newest' | 'starred';
|
||||
export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'random' | 'recentlyPlayed' | 'mostPlayed' | 'recentlyAdded' | 'favourited' | 'starred';
|
||||
|
||||
export type AlbumQuery = Paging & {
|
||||
type: AlbumQueryType;
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -115,8 +120,7 @@ function server(
|
||||
}
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// todo: pass options in here?
|
||||
app.use(express.static("./web/public"));
|
||||
app.use(express.static(path.resolve(__dirname, "..", "web", "public")));
|
||||
app.engine("eta", Eta.renderFile);
|
||||
|
||||
app.set("view engine", "eta");
|
||||
@@ -253,6 +257,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 +311,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>`);
|
||||
});
|
||||
|
||||
|
||||
70
src/smapi.ts
@@ -14,6 +14,7 @@ import {
|
||||
Genre,
|
||||
MusicService,
|
||||
Playlist,
|
||||
Rating,
|
||||
slice2,
|
||||
Track,
|
||||
} from "./music_service";
|
||||
@@ -80,6 +81,13 @@ 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 +308,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 +414,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
getMediaURIResult: bonobUrl
|
||||
.append({
|
||||
pathname: `/stream/${type}/${typeId}`,
|
||||
searchParams: { "bat": accessToken }
|
||||
searchParams: { bat: accessToken },
|
||||
})
|
||||
.href(),
|
||||
})),
|
||||
@@ -503,9 +514,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
case "track":
|
||||
return musicLibrary.track(typeId).then((it) => ({
|
||||
getExtendedMetadataResult: {
|
||||
mediaMetadata: {
|
||||
...track(urlWithToken(accessToken), it),
|
||||
},
|
||||
mediaMetadata: track(urlWithToken(accessToken), it),
|
||||
},
|
||||
}));
|
||||
case "album":
|
||||
@@ -580,6 +589,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: "starredAlbums",
|
||||
title: lang("topRated"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "star").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "playlists",
|
||||
title: lang("playlists"),
|
||||
@@ -597,18 +624,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,6 +704,11 @@ function bindSmapiSoapServiceToExpress(
|
||||
type: "random",
|
||||
...paging,
|
||||
});
|
||||
case "favouriteAlbums":
|
||||
return albums({
|
||||
type: "favourited",
|
||||
...paging,
|
||||
});
|
||||
case "starredAlbums":
|
||||
return albums({
|
||||
type: "starred",
|
||||
@@ -696,17 +716,17 @@ function bindSmapiSoapServiceToExpress(
|
||||
});
|
||||
case "recentlyAdded":
|
||||
return albums({
|
||||
type: "newest",
|
||||
type: "recentlyAdded",
|
||||
...paging,
|
||||
});
|
||||
case "recentlyPlayed":
|
||||
return albums({
|
||||
type: "recent",
|
||||
type: "recentlyPlayed",
|
||||
...paging,
|
||||
});
|
||||
case "mostPlayed":
|
||||
return albums({
|
||||
type: "frequent",
|
||||
type: "mostPlayed",
|
||||
...paging,
|
||||
});
|
||||
case "genres":
|
||||
@@ -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 },
|
||||
_,
|
||||
|
||||
24
src/sonos.ts
@@ -24,25 +24,25 @@ export const SONOS_LANG: LANG[] = [
|
||||
"zh-CN",
|
||||
];
|
||||
|
||||
export const PRESENTATION_AND_STRINGS_VERSION = "21";
|
||||
export const PRESENTATION_AND_STRINGS_VERSION =
|
||||
process.env["BNB_DEBUG"] === "true"
|
||||
? `${Math.round(new Date().getTime() / 1000)}`
|
||||
: "23";
|
||||
|
||||
// NOTE: manifest requires https for the URL,
|
||||
// otherwise you will get an error trying to register
|
||||
// NOTE: manifest requires https for the URL, otherwise you will get an error trying to register
|
||||
export type Capability =
|
||||
| "search"
|
||||
| "trFavorites"
|
||||
| "alFavorites"
|
||||
| "ucPlaylists"
|
||||
| "extendedMD"
|
||||
| "trFavorites" // Favorites: Adding/Removing Tracks (deprecated)
|
||||
| "alFavorites" // Favorites: Adding/Removing Albums (deprecated)
|
||||
| "ucPlaylists" // User Content Playlists
|
||||
| "extendedMD" // Extended Metadata (More Menu, Info & Options)
|
||||
| "contextHeaders"
|
||||
| "authorizationHeader"
|
||||
| "logging"
|
||||
| "logging" // Playback duration logging at track end (deprecated)
|
||||
| "manifest";
|
||||
|
||||
export const BONOB_CAPABILITIES: Capability[] = [
|
||||
"search",
|
||||
// "trFavorites",
|
||||
// "alFavorites",
|
||||
"ucPlaylists",
|
||||
"extendedMD",
|
||||
"logging",
|
||||
@@ -247,9 +247,7 @@ export type Discovery = {
|
||||
seedHost?: string;
|
||||
};
|
||||
|
||||
export default (
|
||||
sonosDiscovery: Discovery = { enabled: true }
|
||||
): Sonos =>
|
||||
export default (sonosDiscovery: Discovery = { enabled: true }): Sonos =>
|
||||
sonosDiscovery.enabled
|
||||
? autoDiscoverySonos(sonosDiscovery.seedHost)
|
||||
: SONOS_DISABLED;
|
||||
|
||||
447
src/subsonic.ts
@@ -18,10 +18,14 @@ import {
|
||||
AlbumSummary,
|
||||
Genre,
|
||||
Track,
|
||||
CoverArt,
|
||||
Rating,
|
||||
AlbumQueryType,
|
||||
} from "./music_service";
|
||||
import X2JS from "x2js";
|
||||
import sharp from "sharp";
|
||||
import _ from "underscore";
|
||||
import fse from "fs-extra";
|
||||
import path from "path";
|
||||
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import { Encryption } from "./encryption";
|
||||
@@ -57,36 +61,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;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
@@ -98,9 +102,9 @@ export type GetAlbumListResponse = SubsonicResponse & {
|
||||
};
|
||||
|
||||
export type genre = {
|
||||
_songCount: string;
|
||||
_albumCount: string;
|
||||
__text: string;
|
||||
songCount: number;
|
||||
albumCount: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type GetGenresResponse = SubsonicResponse & {
|
||||
@@ -111,8 +115,8 @@ export type GetGenresResponse = SubsonicResponse & {
|
||||
|
||||
export type SubsonicError = SubsonicResponse & {
|
||||
error: {
|
||||
_code: string;
|
||||
_message: string;
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -142,22 +146,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 = {
|
||||
@@ -167,31 +174,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[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -211,6 +202,13 @@ export type GetSongResponse = {
|
||||
song: song;
|
||||
};
|
||||
|
||||
export type GetStarredResponse = {
|
||||
starred2: {
|
||||
song: song[];
|
||||
album: album[];
|
||||
};
|
||||
};
|
||||
|
||||
export type Search3Response = SubsonicResponse & {
|
||||
searchResult3: {
|
||||
artist: artistSummary[];
|
||||
@@ -250,29 +248,36 @@ 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),
|
||||
const asAlbum = (album: 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),
|
||||
});
|
||||
|
||||
export const asGenre = (genreName: string) => ({
|
||||
@@ -311,19 +316,73 @@ export const asURLSearchParams = (q: any) => {
|
||||
return urlSearchParams;
|
||||
};
|
||||
|
||||
export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>;
|
||||
|
||||
export const cachingImageFetcher =
|
||||
(cacheDir: string, delegate: ImageFetcher) =>
|
||||
async (url: string): Promise<CoverArt | undefined> => {
|
||||
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
|
||||
return fse
|
||||
.readFile(filename)
|
||||
.then((data) => ({ contentType: "image/png", data }))
|
||||
.catch(() =>
|
||||
delegate(url).then((image) => {
|
||||
if (image) {
|
||||
return sharp(image.data)
|
||||
.png()
|
||||
.toBuffer()
|
||||
.then((png) => {
|
||||
return fse
|
||||
.writeFile(filename, png)
|
||||
.then(() => ({ contentType: "image/png", data: png }));
|
||||
});
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const axiosImageFetcher = (url: string): Promise<CoverArt | undefined> =>
|
||||
axios
|
||||
.get(url, {
|
||||
headers: BROWSER_HEADERS,
|
||||
responseType: "arraybuffer",
|
||||
})
|
||||
.then((res) => ({
|
||||
contentType: res.headers["content-type"],
|
||||
data: Buffer.from(res.data, "binary"),
|
||||
}))
|
||||
.catch(() => undefined);
|
||||
|
||||
const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
||||
alphabeticalByArtist: "alphabeticalByArtist",
|
||||
alphabeticalByName: "alphabeticalByName",
|
||||
byGenre: "byGenre",
|
||||
random: "random",
|
||||
recentlyPlayed: "recent",
|
||||
mostPlayed: "frequent",
|
||||
recentlyAdded: "newest",
|
||||
favourited: "starred",
|
||||
starred: "highest",
|
||||
};
|
||||
|
||||
export class Subsonic implements MusicService {
|
||||
url: string;
|
||||
encryption: Encryption;
|
||||
streamClientApplication: StreamClientApplication;
|
||||
externalImageFetcher: ImageFetcher;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
encryption: Encryption,
|
||||
streamClientApplication: StreamClientApplication = DEFAULT
|
||||
streamClientApplication: StreamClientApplication = DEFAULT,
|
||||
externalImageFetcher: ImageFetcher = axiosImageFetcher
|
||||
) {
|
||||
this.url = url;
|
||||
this.encryption = encryption;
|
||||
this.streamClientApplication = streamClientApplication;
|
||||
this.externalImageFetcher = externalImageFetcher;
|
||||
}
|
||||
|
||||
get = async (
|
||||
@@ -357,31 +416,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;
|
||||
});
|
||||
|
||||
@@ -406,9 +445,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,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -424,9 +463,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",
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -434,13 +473,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 = (
|
||||
@@ -452,8 +491,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 || []),
|
||||
}));
|
||||
|
||||
@@ -481,20 +520,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) =>
|
||||
@@ -509,6 +553,31 @@ export class Subsonic implements MusicService {
|
||||
songs: it.searchResult3.song || [],
|
||||
}));
|
||||
|
||||
getAlbumList2 = (credentials: Credentials, q: AlbumQuery) =>
|
||||
Promise.all([
|
||||
this.getArtists(credentials).then((it) =>
|
||||
_.inject(it, (total, artist) => total + artist.albumCount, 0)
|
||||
),
|
||||
this.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
|
||||
type: AlbumQueryTypeToSubsonicType[q.type],
|
||||
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
|
||||
size: 500,
|
||||
offset: q._index,
|
||||
})
|
||||
.then((response) => response.albumList2.album || [])
|
||||
.then(this.toAlbumSummary),
|
||||
]).then(([total, albums]) => ({
|
||||
results: albums.slice(0, q._count),
|
||||
total: albums.length == 500 ? total : q._index + albums.length,
|
||||
}));
|
||||
|
||||
// getStarred2 = (credentials: Credentials): Promise<{ albums: Album[] }> =>
|
||||
// this.getJSON<GetStarredResponse>(credentials, "/rest/getStarred2")
|
||||
// .then((it) => it.starred2)
|
||||
// .then((it) => ({
|
||||
// albums: it.album.map(asAlbum),
|
||||
// }));
|
||||
|
||||
async login(token: string) {
|
||||
const subsonic = this;
|
||||
const credentials: Credentials = this.parseToken(token);
|
||||
@@ -525,25 +594,7 @@ export class Subsonic implements MusicService {
|
||||
artist: async (id: string): Promise<Artist> =>
|
||||
subsonic.getArtistWithInfo(credentials, id),
|
||||
albums: async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
|
||||
Promise.all([
|
||||
subsonic
|
||||
.getArtists(credentials)
|
||||
.then((it) =>
|
||||
_.inject(it, (total, artist) => total + artist.albumCount, 0)
|
||||
),
|
||||
subsonic
|
||||
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
|
||||
type: q.type,
|
||||
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
|
||||
size: 500,
|
||||
offset: q._index,
|
||||
})
|
||||
.then((response) => response.albumList2.album || [])
|
||||
.then(subsonic.toAlbumSummary),
|
||||
]).then(([total, albums]) => ({
|
||||
results: albums.slice(0, q._count),
|
||||
total: albums.length == 500 ? total : q._index + albums.length,
|
||||
})),
|
||||
subsonic.getAlbumList2(credentials, q),
|
||||
album: (id: string): Promise<Album> => subsonic.getAlbum(credentials, id),
|
||||
genres: () =>
|
||||
subsonic
|
||||
@@ -551,8 +602,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 }))
|
||||
)
|
||||
@@ -567,6 +618,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,
|
||||
@@ -630,28 +715,21 @@ export class Subsonic implements MusicService {
|
||||
(it) => it.coverArt
|
||||
);
|
||||
if (artist.image.large) {
|
||||
return axios
|
||||
.get(artist.image.large!, {
|
||||
headers: BROWSER_HEADERS,
|
||||
responseType: "arraybuffer",
|
||||
})
|
||||
.then((res) => {
|
||||
const image = Buffer.from(res.data, "binary");
|
||||
if (size) {
|
||||
return sharp(image)
|
||||
return this.externalImageFetcher(artist.image.large!).then(
|
||||
(image) => {
|
||||
if (image && size) {
|
||||
return sharp(image.data)
|
||||
.resize(size)
|
||||
.toBuffer()
|
||||
.then((resized) => ({
|
||||
contentType: res.headers["content-type"],
|
||||
contentType: image.contentType,
|
||||
data: resized,
|
||||
}));
|
||||
} else {
|
||||
return {
|
||||
contentType: res.headers["content-type"],
|
||||
data: image,
|
||||
};
|
||||
return image;
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
} else if (albumsWithCoverArt.length > 0) {
|
||||
return subsonic
|
||||
.getCoverArt(
|
||||
@@ -675,7 +753,7 @@ export class Subsonic implements MusicService {
|
||||
},
|
||||
scrobble: async (id: string) =>
|
||||
subsonic
|
||||
.get(credentials, `/rest/scrobble`, {
|
||||
.getJSON(credentials, `/rest/scrobble`, {
|
||||
id,
|
||||
submission: true,
|
||||
})
|
||||
@@ -683,7 +761,7 @@ export class Subsonic implements MusicService {
|
||||
.catch(() => false),
|
||||
nowPlaying: async (id: string) =>
|
||||
subsonic
|
||||
.get(credentials, `/rest/scrobble`, {
|
||||
.getJSON(credentials, `/rest/scrobble`, {
|
||||
id,
|
||||
submission: false,
|
||||
})
|
||||
@@ -694,8 +772,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) =>
|
||||
@@ -707,7 +785,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 () =>
|
||||
@@ -715,7 +793,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
|
||||
@@ -726,29 +804,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,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}),
|
||||
@@ -758,7 +829,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", {
|
||||
@@ -791,7 +862,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))
|
||||
)
|
||||
)
|
||||
@@ -808,7 +879,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))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,7 +3,15 @@ import { v4 as uuid } from "uuid";
|
||||
import { Credentials } from "../src/smapi";
|
||||
|
||||
import { Service, Device } from "../src/sonos";
|
||||
import { Album, Artist, Track, albumToAlbumSummary, artistToArtistSummary, PlaylistSummary, Playlist } from "../src/music_service";
|
||||
import {
|
||||
Album,
|
||||
Artist,
|
||||
Track,
|
||||
albumToAlbumSummary,
|
||||
artistToArtistSummary,
|
||||
PlaylistSummary,
|
||||
Playlist,
|
||||
} from "../src/music_service";
|
||||
import randomString from "../src/random_string";
|
||||
import { b64Encode } from "../src/b64";
|
||||
|
||||
@@ -29,12 +37,14 @@ export const aService = (fields: Partial<Service> = {}): Service => ({
|
||||
...fields,
|
||||
});
|
||||
|
||||
export function aPlaylistSummary(fields: Partial<PlaylistSummary> = {}): PlaylistSummary {
|
||||
export function aPlaylistSummary(
|
||||
fields: Partial<PlaylistSummary> = {}
|
||||
): PlaylistSummary {
|
||||
return {
|
||||
id: `playlist-${uuid()}`,
|
||||
name: `playlistname-${randomString()}`,
|
||||
...fields
|
||||
}
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
export function aPlaylist(fields: Partial<Playlist> = {}): Playlist {
|
||||
@@ -42,8 +52,8 @@ export function aPlaylist(fields: Partial<Playlist> = {}): Playlist {
|
||||
id: `playlist-${uuid()}`,
|
||||
name: `playlist-${randomString()}`,
|
||||
entries: [aTrack(), aTrack()],
|
||||
...fields
|
||||
}
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
export function aDevice(fields: Partial<Device> = {}): Device {
|
||||
@@ -105,14 +115,14 @@ export function anArtist(fields: Partial<Artist> = {}): Artist {
|
||||
],
|
||||
...fields,
|
||||
};
|
||||
artist.albums.forEach(album => {
|
||||
artist.albums.forEach((album) => {
|
||||
album.artistId = artist.id;
|
||||
album.artistName = artist.name;
|
||||
})
|
||||
});
|
||||
return artist;
|
||||
}
|
||||
|
||||
export const aGenre = (name: string) => ({ id: b64Encode(name), name })
|
||||
export const aGenre = (name: string) => ({ id: b64Encode(name), name });
|
||||
|
||||
export const HIP_HOP = aGenre("Hip-Hop");
|
||||
export const METAL = aGenre("Metal");
|
||||
@@ -125,13 +135,23 @@ export const SKA = aGenre("Ska");
|
||||
export const PUNK = aGenre("Punk");
|
||||
export const TRIP_HOP = aGenre("Trip Hop");
|
||||
|
||||
export const SAMPLE_GENRES = [HIP_HOP, METAL, NEW_WAVE, POP, POP_ROCK, REGGAE, ROCK, SKA];
|
||||
export const SAMPLE_GENRES = [
|
||||
HIP_HOP,
|
||||
METAL,
|
||||
NEW_WAVE,
|
||||
POP,
|
||||
POP_ROCK,
|
||||
REGGAE,
|
||||
ROCK,
|
||||
SKA,
|
||||
];
|
||||
export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)];
|
||||
|
||||
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}`,
|
||||
@@ -140,11 +160,14 @@ export function aTrack(fields: Partial<Track> = {}): Track {
|
||||
number: randomInt(100),
|
||||
genre,
|
||||
artist: artistToArtistSummary(artist),
|
||||
album: albumToAlbumSummary(anAlbum({ artistId: artist.id, artistName: artist.name, genre })),
|
||||
album: albumToAlbumSummary(
|
||||
anAlbum({ artistId: artist.id, artistName: artist.name, genre })
|
||||
),
|
||||
coverArt: `coverArt:${uuid()}`,
|
||||
rating,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function anAlbum(fields: Partial<Album> = {}): Album {
|
||||
const id = uuid();
|
||||
@@ -173,7 +196,7 @@ export const BLONDIE: Artist = {
|
||||
genre: NEW_WAVE,
|
||||
artistId: BLONDIE_ID,
|
||||
artistName: BLONDIE_NAME,
|
||||
coverArt: `coverArt:${uuid()}`
|
||||
coverArt: `coverArt:${uuid()}`,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
@@ -182,7 +205,7 @@ export const BLONDIE: Artist = {
|
||||
genre: POP_ROCK,
|
||||
artistId: BLONDIE_ID,
|
||||
artistName: BLONDIE_NAME,
|
||||
coverArt: `coverArt:${uuid()}`
|
||||
coverArt: `coverArt:${uuid()}`,
|
||||
},
|
||||
],
|
||||
image: {
|
||||
@@ -199,9 +222,33 @@ export const BOB_MARLEY: Artist = {
|
||||
id: BOB_MARLEY_ID,
|
||||
name: BOB_MARLEY_NAME,
|
||||
albums: [
|
||||
{ id: uuid(), name: "Burin'", year: "1973", genre: REGGAE, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, coverArt: `coverArt:${uuid()}` },
|
||||
{ id: uuid(), name: "Exodus", year: "1977", genre: REGGAE, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, coverArt: `coverArt:${uuid()}` },
|
||||
{ id: uuid(), name: "Kaya", year: "1978", genre: SKA, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, coverArt: `coverArt:${uuid()}` },
|
||||
{
|
||||
id: uuid(),
|
||||
name: "Burin'",
|
||||
year: "1973",
|
||||
genre: REGGAE,
|
||||
artistId: BOB_MARLEY_ID,
|
||||
artistName: BOB_MARLEY_NAME,
|
||||
coverArt: `coverArt:${uuid()}`,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
name: "Exodus",
|
||||
year: "1977",
|
||||
genre: REGGAE,
|
||||
artistId: BOB_MARLEY_ID,
|
||||
artistName: BOB_MARLEY_NAME,
|
||||
coverArt: `coverArt:${uuid()}`,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
name: "Kaya",
|
||||
year: "1978",
|
||||
genre: SKA,
|
||||
artistId: BOB_MARLEY_ID,
|
||||
artistName: BOB_MARLEY_NAME,
|
||||
coverArt: `coverArt:${uuid()}`,
|
||||
},
|
||||
],
|
||||
image: {
|
||||
small: "http://localhost/BOB_MARLEY/sml",
|
||||
@@ -238,7 +285,7 @@ export const METALLICA: Artist = {
|
||||
genre: METAL,
|
||||
artistId: METALLICA_ID,
|
||||
artistName: METALLICA_NAME,
|
||||
coverArt: `coverArt:${uuid()}`
|
||||
coverArt: `coverArt:${uuid()}`,
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
@@ -247,7 +294,7 @@ export const METALLICA: Artist = {
|
||||
genre: METAL,
|
||||
artistId: METALLICA_ID,
|
||||
artistName: METALLICA_NAME,
|
||||
coverArt: `coverArt:${uuid()}`
|
||||
coverArt: `coverArt:${uuid()}`,
|
||||
},
|
||||
],
|
||||
image: {
|
||||
@@ -261,3 +308,4 @@ export const METALLICA: Artist = {
|
||||
export const ALL_ARTISTS = [BOB_MARLEY, BLONDIE, MADONNA, METALLICA];
|
||||
|
||||
export const ALL_ALBUMS = ALL_ARTISTS.flatMap((it) => it.albums || []);
|
||||
|
||||
|
||||
@@ -66,11 +66,13 @@ describe("envVar", () => {
|
||||
|
||||
describe("validationPattern", () => {
|
||||
it("should fail when the value does not match the pattern", () => {
|
||||
expect(
|
||||
() => envVar("bnb-var", {
|
||||
expect(() =>
|
||||
envVar("bnb-var", {
|
||||
validationPattern: /^foobar$/,
|
||||
})
|
||||
).toThrowError(`Invalid value specified for 'bnb-var', must match ${/^foobar$/}`)
|
||||
).toThrowError(
|
||||
`Invalid value specified for 'bnb-var', must match ${/^foobar$/}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -117,7 +119,7 @@ describe("config", () => {
|
||||
}
|
||||
|
||||
describe("bonobUrl", () => {
|
||||
["BNB_URL", "BONOB_URL", "BONOB_WEB_ADDRESS"].forEach(key => {
|
||||
["BNB_URL", "BONOB_URL", "BONOB_WEB_ADDRESS"].forEach((key) => {
|
||||
describe(`when ${key} is specified`, () => {
|
||||
it("should be used", () => {
|
||||
const url = "http://bonob1.example.com:8877/";
|
||||
@@ -163,7 +165,8 @@ describe("config", () => {
|
||||
|
||||
describe("icons", () => {
|
||||
describe("foregroundColor", () => {
|
||||
["BNB_ICON_FOREGROUND_COLOR", "BONOB_ICON_FOREGROUND_COLOR"].forEach(k => {
|
||||
["BNB_ICON_FOREGROUND_COLOR", "BONOB_ICON_FOREGROUND_COLOR"].forEach(
|
||||
(k) => {
|
||||
describe(`when ${k} is not specified`, () => {
|
||||
it(`should default to undefined`, () => {
|
||||
expect(config().icons.foregroundColor).toEqual(undefined);
|
||||
@@ -192,11 +195,13 @@ describe("config", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("backgroundColor", () => {
|
||||
["BNB_ICON_BACKGROUND_COLOR", "BONOB_ICON_BACKGROUND_COLOR"].forEach(k => {
|
||||
["BNB_ICON_BACKGROUND_COLOR", "BONOB_ICON_BACKGROUND_COLOR"].forEach(
|
||||
(k) => {
|
||||
describe(`when ${k} is not specified`, () => {
|
||||
it(`should default to undefined`, () => {
|
||||
expect(config().icons.backgroundColor).toEqual(undefined);
|
||||
@@ -225,7 +230,8 @@ describe("config", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,7 +240,7 @@ describe("config", () => {
|
||||
expect(config().secret).toEqual("bonob");
|
||||
});
|
||||
|
||||
["BNB_SECRET", "BONOB_SECRET"].forEach(key => {
|
||||
["BNB_SECRET", "BONOB_SECRET"].forEach((key) => {
|
||||
it(`should be overridable using ${key}`, () => {
|
||||
process.env[key] = "new secret";
|
||||
expect(config().secret).toEqual("new secret");
|
||||
@@ -248,7 +254,7 @@ describe("config", () => {
|
||||
expect(config().sonos.serviceName).toEqual("bonob");
|
||||
});
|
||||
|
||||
["BNB_SONOS_SERVICE_NAME", "BONOB_SONOS_SERVICE_NAME"].forEach(k => {
|
||||
["BNB_SONOS_SERVICE_NAME", "BONOB_SONOS_SERVICE_NAME"].forEach((k) => {
|
||||
it("should be overridable", () => {
|
||||
process.env[k] = "foobar1000";
|
||||
expect(config().sonos.serviceName).toEqual("foobar1000");
|
||||
@@ -256,21 +262,23 @@ describe("config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
["BNB_SONOS_DEVICE_DISCOVERY", "BONOB_SONOS_DEVICE_DISCOVERY"].forEach(k => {
|
||||
["BNB_SONOS_DEVICE_DISCOVERY", "BONOB_SONOS_DEVICE_DISCOVERY"].forEach(
|
||||
(k) => {
|
||||
describeBooleanConfigValue(
|
||||
"deviceDiscovery",
|
||||
k,
|
||||
true,
|
||||
(config) => config.sonos.discovery.enabled
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
describe("seedHost", () => {
|
||||
it("should default to undefined", () => {
|
||||
expect(config().sonos.discovery.seedHost).toBeUndefined();
|
||||
});
|
||||
|
||||
["BNB_SONOS_SEED_HOST", "BONOB_SONOS_SEED_HOST"].forEach(k => {
|
||||
["BNB_SONOS_SEED_HOST", "BONOB_SONOS_SEED_HOST"].forEach((k) => {
|
||||
it("should be overridable", () => {
|
||||
process.env[k] = "123.456.789.0";
|
||||
expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0");
|
||||
@@ -278,7 +286,7 @@ describe("config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
["BNB_SONOS_AUTO_REGISTER", "BONOB_SONOS_AUTO_REGISTER"].forEach(k => {
|
||||
["BNB_SONOS_AUTO_REGISTER", "BONOB_SONOS_AUTO_REGISTER"].forEach((k) => {
|
||||
describeBooleanConfigValue(
|
||||
"autoRegister",
|
||||
k,
|
||||
@@ -287,13 +295,12 @@ describe("config", () => {
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
describe("sid", () => {
|
||||
it("should default to 246", () => {
|
||||
expect(config().sonos.sid).toEqual(246);
|
||||
});
|
||||
|
||||
["BNB_SONOS_SERVICE_ID", "BONOB_SONOS_SERVICE_ID"].forEach(k => {
|
||||
["BNB_SONOS_SERVICE_ID", "BONOB_SONOS_SERVICE_ID"].forEach((k) => {
|
||||
it("should be overridable", () => {
|
||||
process.env[k] = "786";
|
||||
expect(config().sonos.sid).toEqual(786);
|
||||
@@ -304,17 +311,22 @@ describe("config", () => {
|
||||
|
||||
describe("subsonic", () => {
|
||||
describe("url", () => {
|
||||
["BNB_SUBSONIC_URL", "BONOB_SUBSONIC_URL", "BONOB_NAVIDROME_URL"].forEach(k => {
|
||||
["BNB_SUBSONIC_URL", "BONOB_SUBSONIC_URL", "BONOB_NAVIDROME_URL"].forEach(
|
||||
(k) => {
|
||||
describe(`when ${k} is not specified`, () => {
|
||||
it(`should default to http://${hostname()}:4533`, () => {
|
||||
expect(config().subsonic.url).toEqual(`http://${hostname()}:4533`);
|
||||
expect(config().subsonic.url).toEqual(
|
||||
`http://${hostname()}:4533`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`when ${k} is ''`, () => {
|
||||
it(`should default to http://${hostname()}:4533`, () => {
|
||||
process.env[k] = "";
|
||||
expect(config().subsonic.url).toEqual(`http://${hostname()}:4533`);
|
||||
expect(config().subsonic.url).toEqual(
|
||||
`http://${hostname()}:4533`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,7 +337,8 @@ describe("config", () => {
|
||||
expect(config().subsonic.url).toEqual(url);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe("customClientsFor", () => {
|
||||
@@ -333,17 +346,31 @@ describe("config", () => {
|
||||
expect(config().subsonic.customClientsFor).toBeUndefined();
|
||||
});
|
||||
|
||||
["BNB_SUBSONIC_CUSTOM_CLIENTS", "BONOB_SUBSONIC_CUSTOM_CLIENTS", "BONOB_NAVIDROME_CUSTOM_CLIENTS"].forEach(k => {
|
||||
[
|
||||
"BNB_SUBSONIC_CUSTOM_CLIENTS",
|
||||
"BONOB_SUBSONIC_CUSTOM_CLIENTS",
|
||||
"BONOB_NAVIDROME_CUSTOM_CLIENTS",
|
||||
].forEach((k) => {
|
||||
it(`should be overridable for ${k}`, () => {
|
||||
process.env[k] = "whoop/whoop";
|
||||
expect(config().subsonic.customClientsFor).toEqual("whoop/whoop");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("artistImageCache", () => {
|
||||
it("should default to undefined", () => {
|
||||
expect(config().subsonic.artistImageCache).toBeUndefined();
|
||||
});
|
||||
|
||||
it(`should be overridable for BNB_SUBSONIC_ARTIST_IMAGE_CACHE`, () => {
|
||||
process.env["BNB_SUBSONIC_ARTIST_IMAGE_CACHE"] = "/some/path";
|
||||
expect(config().subsonic.artistImageCache).toEqual("/some/path");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
["BNB_SCROBBLE_TRACKS", "BONOB_SCROBBLE_TRACKS"].forEach(k => {
|
||||
["BNB_SCROBBLE_TRACKS", "BONOB_SCROBBLE_TRACKS"].forEach((k) => {
|
||||
describeBooleanConfigValue(
|
||||
"scrobbleTracks",
|
||||
k,
|
||||
@@ -352,7 +379,7 @@ describe("config", () => {
|
||||
);
|
||||
});
|
||||
|
||||
["BNB_REPORT_NOW_PLAYING", "BONOB_REPORT_NOW_PLAYING"].forEach(k => {
|
||||
["BNB_REPORT_NOW_PLAYING", "BONOB_REPORT_NOW_PLAYING"].forEach((k) => {
|
||||
describeBooleanConfigValue(
|
||||
"reportNowPlaying",
|
||||
k,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
albumToAlbumSummary,
|
||||
Track,
|
||||
Genre,
|
||||
Rating,
|
||||
} from "../src/music_service";
|
||||
|
||||
export class InMemoryMusicService implements MusicService {
|
||||
@@ -76,7 +77,9 @@ export class InMemoryMusicService implements MusicService {
|
||||
case "alphabeticalByArtist":
|
||||
return artist2Album;
|
||||
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":
|
||||
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([]),
|
||||
});
|
||||
|
||||
@@ -1668,7 +1668,7 @@ describe("server", () => {
|
||||
"playlists",
|
||||
"genres",
|
||||
"random",
|
||||
"starred",
|
||||
"heart",
|
||||
"recentlyAdded",
|
||||
"recentlyPlayed",
|
||||
"mostPlayed",
|
||||
|
||||
@@ -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,7 +101,6 @@ describe("service config", () => {
|
||||
pathname: PRESENTATION_MAP_ROUTE,
|
||||
});
|
||||
|
||||
describe(STRINGS_ROUTE, () => {
|
||||
async function fetchStringsXml() {
|
||||
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 () => {
|
||||
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,24 @@ 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: "starredAlbums",
|
||||
title: "Top Rated",
|
||||
albumArtURI: iconArtURI(bonobUrl, "star").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "playlists",
|
||||
title: "Playlists",
|
||||
@@ -885,18 +1000,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 +1058,24 @@ 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: "starredAlbums",
|
||||
title: "Best beoordeeld",
|
||||
albumArtURI: iconArtURI(bonobUrl, "star").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "playlists",
|
||||
title: "Afspeellijsten",
|
||||
@@ -972,18 +1093,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,6 +1677,54 @@ describe("api", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for favourite albums", () => {
|
||||
const albums = [rock2, rock1, pop2];
|
||||
|
||||
beforeEach(() => {
|
||||
musicLibrary.albums.mockResolvedValue({
|
||||
results: albums,
|
||||
total: allAlbums.length,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return some", async () => {
|
||||
const paging = {
|
||||
index: 0,
|
||||
count: 100,
|
||||
};
|
||||
|
||||
const result = await ws.getMetadataAsync({
|
||||
id: "favouriteAlbums",
|
||||
...paging,
|
||||
});
|
||||
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: albums.map((it) => ({
|
||||
itemType: "album",
|
||||
id: `album:${it.id}`,
|
||||
title: it.name,
|
||||
albumArtURI: defaultAlbumArtURI(
|
||||
bonobUrlWithAccessToken,
|
||||
it
|
||||
).href(),
|
||||
canPlay: true,
|
||||
artistId: `artist:${it.artistId}`,
|
||||
artist: it.artistName,
|
||||
})),
|
||||
index: 0,
|
||||
total: 6,
|
||||
})
|
||||
);
|
||||
|
||||
expect(musicLibrary.albums).toHaveBeenCalledWith({
|
||||
type: "favourited",
|
||||
_index: paging.index,
|
||||
_count: paging.count,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for starred albums", () => {
|
||||
const albums = [rock2, rock1, pop2];
|
||||
|
||||
@@ -1657,7 +1814,7 @@ describe("api", () => {
|
||||
);
|
||||
|
||||
expect(musicLibrary.albums).toHaveBeenCalledWith({
|
||||
type: "recent",
|
||||
type: "recentlyPlayed",
|
||||
_index: paging.index,
|
||||
_count: paging.count,
|
||||
});
|
||||
@@ -1705,7 +1862,7 @@ describe("api", () => {
|
||||
);
|
||||
|
||||
expect(musicLibrary.albums).toHaveBeenCalledWith({
|
||||
type: "frequent",
|
||||
type: "mostPlayed",
|
||||
_index: paging.index,
|
||||
_count: paging.count,
|
||||
});
|
||||
@@ -1753,7 +1910,7 @@ describe("api", () => {
|
||||
);
|
||||
|
||||
expect(musicLibrary.albums).toHaveBeenCalledWith({
|
||||
type: "newest",
|
||||
type: "recentlyAdded",
|
||||
_index: paging.index,
|
||||
_count: paging.count,
|
||||
});
|
||||
@@ -2325,6 +2482,7 @@ describe("api", () => {
|
||||
});
|
||||
|
||||
describe("asking for a track", () => {
|
||||
describe("that has a love", () => {
|
||||
it("should return the track", async () => {
|
||||
const track = aTrack();
|
||||
|
||||
@@ -2357,6 +2515,9 @@ describe("api", () => {
|
||||
).href(),
|
||||
trackNumber: track.number,
|
||||
},
|
||||
dynamic: {
|
||||
property: [{ name: "rating", value: `${ratingAsInt(track.rating)}` }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2364,6 +2525,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", () => {
|
||||
it("should return the album", async () => {
|
||||
const album = anAlbum();
|
||||
@@ -2471,7 +2676,7 @@ describe("api", () => {
|
||||
getMediaURIResult: bonobUrl
|
||||
.append({
|
||||
pathname: `/stream/track/${trackId}`,
|
||||
searchParams: { "bat": accessToken }
|
||||
searchParams: { bat: accessToken },
|
||||
})
|
||||
.href(),
|
||||
});
|
||||
@@ -2788,6 +2993,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 +3075,7 @@ describe("api", () => {
|
||||
}: {
|
||||
trackId: string;
|
||||
secondsPlayed: number;
|
||||
shouldMarkNowPlaying: boolean,
|
||||
shouldMarkNowPlaying: boolean;
|
||||
}) {
|
||||
it("should scrobble", async () => {
|
||||
musicLibrary.scrobble.mockResolvedValue(true);
|
||||
@@ -2827,7 +3090,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 +3105,7 @@ describe("api", () => {
|
||||
}: {
|
||||
trackId: string;
|
||||
secondsPlayed: number;
|
||||
shouldMarkNowPlaying: boolean,
|
||||
shouldMarkNowPlaying: boolean;
|
||||
}) {
|
||||
it("should scrobble", async () => {
|
||||
const result = await ws.setPlayedSecondsAsync({
|
||||
@@ -2855,7 +3118,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 +3134,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 +3182,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 +3230,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 |
74
yarn.lock
@@ -1131,6 +1131,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/fs-extra@npm:^9.0.13":
|
||||
version: 9.0.13
|
||||
resolution: "@types/fs-extra@npm:9.0.13"
|
||||
dependencies:
|
||||
"@types/node": "*"
|
||||
checksum: add79e212acd5ac76b97b9045834e03a7996aef60a814185e0459088fd290519a3c1620865d588fa36c4498bf614210d2a703af5cf80aa1dbc125db78f6edac3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/graceful-fs@npm:^4.1.2":
|
||||
version: 4.1.5
|
||||
resolution: "@types/graceful-fs@npm:4.1.5"
|
||||
@@ -1303,6 +1312,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/tmp@npm:^0.2.1":
|
||||
version: 0.2.1
|
||||
resolution: "@types/tmp@npm:0.2.1"
|
||||
checksum: 2617d2a04811ca78a8d21f5ffc3bd7c392e03c440053a615b091f3e3726540d36babffc750614a803c81b9f2c5f218cdafc748d8cf4638eade2962f8ccddd2fa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/underscore@npm:^1.11.3":
|
||||
version: 1.11.3
|
||||
resolution: "@types/underscore@npm:1.11.3"
|
||||
@@ -1333,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
|
||||
@@ -1764,12 +1780,14 @@ __metadata:
|
||||
"@svrooij/sonos": ^2.4.0
|
||||
"@types/chai": ^4.2.21
|
||||
"@types/express": ^4.17.13
|
||||
"@types/fs-extra": ^9.0.13
|
||||
"@types/jest": ^27.0.1
|
||||
"@types/mocha": ^9.0.0
|
||||
"@types/morgan": ^1.9.3
|
||||
"@types/node": ^16.7.13
|
||||
"@types/sharp": ^0.28.6
|
||||
"@types/supertest": ^2.0.11
|
||||
"@types/tmp": ^0.2.1
|
||||
"@types/underscore": ^1.11.3
|
||||
"@types/uuid": ^8.3.1
|
||||
axios: ^0.21.4
|
||||
@@ -1778,6 +1796,7 @@ __metadata:
|
||||
eta: ^1.12.3
|
||||
express: ^4.17.1
|
||||
fp-ts: ^2.11.1
|
||||
fs-extra: ^10.0.0
|
||||
get-port: ^5.1.1
|
||||
image-js: ^0.33.0
|
||||
jest: ^27.1.0
|
||||
@@ -1788,6 +1807,7 @@ __metadata:
|
||||
sharp: ^0.29.1
|
||||
soap: ^0.42.0
|
||||
supertest: ^6.1.6
|
||||
tmp: ^0.2.1
|
||||
ts-jest: ^27.0.5
|
||||
ts-md5: ^1.2.9
|
||||
ts-mockito: ^2.6.1
|
||||
@@ -1796,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
|
||||
@@ -3162,6 +3181,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fs-extra@npm:^10.0.0":
|
||||
version: 10.0.0
|
||||
resolution: "fs-extra@npm:10.0.0"
|
||||
dependencies:
|
||||
graceful-fs: ^4.2.0
|
||||
jsonfile: ^6.0.1
|
||||
universalify: ^2.0.0
|
||||
checksum: 5285a3d8f34b917cf2b66af8c231a40c1623626e9d701a20051d3337be16c6d7cac94441c8b3732d47a92a2a027886ca93c69b6a4ae6aee3c89650d2a8880c0a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fs-minipass@npm:^2.0.0":
|
||||
version: 2.1.0
|
||||
resolution: "fs-minipass@npm:2.1.0"
|
||||
@@ -3362,7 +3392,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graceful-fs@npm:^4.2.6":
|
||||
"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6":
|
||||
version: 4.2.8
|
||||
resolution: "graceful-fs@npm:4.2.8"
|
||||
checksum: 5d224c8969ad0581d551dfabdb06882706b31af2561bd5e2034b4097e67cc27d05232849b8643866585fd0a41c7af152950f8776f4dd5579e9853733f31461c6
|
||||
@@ -4598,6 +4628,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jsonfile@npm:^6.0.1":
|
||||
version: 6.1.0
|
||||
resolution: "jsonfile@npm:6.1.0"
|
||||
dependencies:
|
||||
graceful-fs: ^4.1.6
|
||||
universalify: ^2.0.0
|
||||
dependenciesMeta:
|
||||
graceful-fs:
|
||||
optional: true
|
||||
checksum: 7af3b8e1ac8fe7f1eccc6263c6ca14e1966fcbc74b618d3c78a0a2075579487547b94f72b7a1114e844a1e15bb00d440e5d1720bfc4612d790a6f285d5ea8354
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"keyv@npm:^3.0.0":
|
||||
version: 3.1.0
|
||||
resolution: "keyv@npm:3.1.0"
|
||||
@@ -6685,6 +6728,15 @@ resolve@^1.20.0:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tmp@npm:^0.2.1":
|
||||
version: 0.2.1
|
||||
resolution: "tmp@npm:0.2.1"
|
||||
dependencies:
|
||||
rimraf: ^3.0.0
|
||||
checksum: 8b1214654182575124498c87ca986ac53dc76ff36e8f0e0b67139a8d221eaecfdec108c0e6ec54d76f49f1f72ab9325500b246f562b926f85bcdfca8bf35df9e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tmpl@npm:1.0.x":
|
||||
version: 1.0.4
|
||||
resolution: "tmpl@npm:1.0.4"
|
||||
@@ -6992,6 +7044,13 @@ typescript@^4.4.2:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"universalify@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "universalify@npm:2.0.0"
|
||||
checksum: 2406a4edf4a8830aa6813278bab1f953a8e40f2f63a37873ffa9a3bc8f9745d06cc8e88f3572cb899b7e509013f7f6fcc3e37e8a6d914167a5381d8440518c44
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"unpipe@npm:1.0.0, unpipe@npm:~1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "unpipe@npm:1.0.0"
|
||||
@@ -7260,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"
|
||||
|
||||