Compare commits

...

21 Commits

Author SHA1 Message Date
simon
6897397c28 Move artists tests 2025-02-23 03:07:15 +00:00
simon
3a14b62de4 msg 2025-02-22 04:34:55 +00:00
simon
9e5df22701 Artist tests moved around 2025-02-22 04:29:04 +00:00
simojenki
e29d5c5d24 getJSON private 2025-02-17 07:01:34 +00:00
simojenki
b97590dd36 more 2025-02-17 05:47:19 +00:00
simojenki
b0dc11abcb move stream 2025-02-17 00:38:43 +00:00
simon
5009732da2 move scrobble into subsonic 2025-02-15 22:56:22 +00:00
simojenki
ddde55d02b move some code 2025-02-15 11:34:59 +00:00
simojenki
0602e1f077 Remove tracks function, replace with just getting album 2025-02-15 06:48:23 +00:00
simojenki
7eeedff040 bump node, fix app 2025-02-15 06:22:38 +00:00
simojenki
0451c3a931 tests passing 2025-02-15 06:12:29 +00:00
simojenki
cc0dc3704d Move getGenres onto subsonic 2025-02-10 20:49:22 +00:00
simon
dabb7d0f12 bob 2025-02-10 19:35:42 +00:00
simon
a38ca831df Move subsonic music service/library into own file 2025-02-08 02:59:38 +00:00
Simon J
2961b651d9 Icons for years (#220) 2025-02-07 11:52:59 +11:00
Simon J
d8d532e35f bump node to v22 (#218) 2025-02-04 20:14:46 +11:00
Simon J
a581100d29 Removed libxmljs2 (#219) 2025-02-04 19:56:45 +11:00
Simon J
6bc4c79f02 pull subsonic out into proper class (#217) 2025-02-04 06:28:45 +11:00
Simon J
dd52c5706b Update sonos wsdl (#215) 2025-02-01 15:03:37 +11:00
Simon J
996582ce93 bump libs (#211) 2024-11-30 21:30:30 +11:00
Jonathan Virga
0488f398c1 Add years menu (#202) 2024-04-23 10:06:18 +10:00
30 changed files with 7010 additions and 6216 deletions

View File

@@ -1,4 +1,4 @@
FROM node:20-bullseye FROM node:23-bullseye
LABEL maintainer=simojenki LABEL maintainer=simojenki

View File

@@ -1,4 +1,4 @@
FROM node:20-bullseye-slim as build FROM node:23-bullseye-slim AS build
WORKDIR /bonob WORKDIR /bonob
@@ -36,7 +36,7 @@ RUN apt-get update && \
NODE_ENV=production npm install --omit=dev NODE_ENV=production npm install --omit=dev
FROM node:20-bullseye-slim FROM node:23-bullseye-slim
LABEL maintainer="simojenki" \ LABEL maintainer="simojenki" \
org.opencontainers.image.source="https://github.com/simojenki/bonob" \ org.opencontainers.image.source="https://github.com/simojenki/bonob" \
@@ -58,7 +58,7 @@ COPY --from=build /bonob/build/src ./src
COPY --from=build /bonob/node_modules ./node_modules COPY --from=build /bonob/node_modules ./node_modules
COPY --from=build /bonob/.gitinfo ./ COPY --from=build /bonob/.gitinfo ./
COPY web ./web COPY web ./web
COPY src/Sonoswsdl-1.19.4-20190411.142401-3.wsdl ./src/Sonoswsdl-1.19.4-20190411.142401-3.wsdl COPY src/Sonoswsdl-1.19.6-20231024.wsdl ./src/Sonoswsdl-1.19.6-20231024.wsdl
RUN apt-get update && \ RUN apt-get update && \
apt-get -y upgrade && \ apt-get -y upgrade && \

View File

@@ -9,14 +9,14 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
## Features ## Features
- Integrates with Subsonic API clones (Navidrome, Gonic) - Integrates with Subsonic API clones (Navidrome, Gonic)
- Browse by Artist, Albums, Random, Favourites, Top Rated, Playlist, Genres, Recently Added Albums, Recently Played Albums, Most Played Albums - Browse by Artist, Albums, Random, Favourites, Top Rated, Playlist, Genres, Years, Recently Added Albums, Recently Played Albums, Most Played Albums
- Artist & Album Art - Artist & Album Art
- View Related Artists via Artist -> '...' -> Menu -> Related Arists - View Related Artists via Artist -> '...' -> Menu -> Related Arists
- Now playing & Track Scrobbling - Now playing & Track Scrobbling
- Search by Album, Artist, Track - Search by Album, Artist, Track
- Playlist editing through sonos app. - Playlist editing through sonos app.
- Marking of songs as favourites and with ratings through the sonos app. - Marking of songs as favourites and with ratings through the sonos app.
- Localization (only en-US, da-DK & nl-NL supported currently, require translations for other languages). [Sonos localization and supported languages](https://docs.sonos.com/docs/localization) - Localization (only en-US, da-DK, nl-NL & fr-FR supported currently, require translations for other languages). [Sonos localization and supported languages](https://docs.sonos.com/docs/localization)
- Auto discovery of sonos devices - Auto discovery of sonos devices
- Discovery of sonos devices using seed IP address - Discovery of sonos devices using seed IP address
- Auto registration with sonos on start - Auto registration with sonos on start

1690
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,56 +6,56 @@
"author": "simojenki <simojenki@users.noreply.github.com>", "author": "simojenki <simojenki@users.noreply.github.com>",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@svrooij/sonos": "^2.6.0-beta.7", "@svrooij/sonos": "^2.6.0-beta.11",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jsonwebtoken": "^9.0.5", "@types/jsonwebtoken": "^9.0.7",
"@types/jws": "^3.2.9", "@types/jws": "^3.2.10",
"@types/morgan": "^1.9.9", "@types/morgan": "^1.9.9",
"@types/node": "^20.11.5", "@types/node": "^20.11.5",
"@types/randomstring": "^1.1.11", "@types/randomstring": "^1.3.0",
"@types/underscore": "^1.11.15", "@types/underscore": "^1.13.0",
"@types/uuid": "^9.0.7", "@types/uuid": "^10.0.0",
"@types/xmldom": "0.1.34", "@types/xmldom": "^0.1.34",
"axios": "^1.6.5", "@xmldom/xmldom": "^0.9.7",
"dayjs": "^1.11.10", "axios": "^1.7.8",
"dayjs": "^1.11.13",
"eta": "^2.2.0", "eta": "^2.2.0",
"express": "^4.18.2", "express": "^4.18.3",
"fp-ts": "^2.16.2", "fp-ts": "^2.16.9",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jws": "^4.0.0", "jws": "^4.0.0",
"libxmljs2": "^0.33.0",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"node-html-parser": "^6.1.12", "node-html-parser": "^6.1.13",
"randomstring": "^1.3.0", "randomstring": "^1.3.0",
"sharp": "^0.33.2", "sharp": "^0.33.5",
"soap": "^1.0.0", "soap": "^1.1.6",
"ts-md5": "^1.3.1", "ts-md5": "^1.3.1",
"typescript": "^5.3.3", "typescript": "^5.7.2",
"underscore": "^1.13.6", "underscore": "^1.13.7",
"urn-lib": "^2.0.0", "urn-lib": "^2.0.0",
"uuid": "^9.0.1", "uuid": "^11.0.3",
"winston": "^3.11.0", "winston": "^3.17.0",
"xmldom-ts": "^0.3.1" "xmldom-ts": "^0.3.1",
"xpath": "^0.0.34"
}, },
"devDependencies": { "devDependencies": {
"@types/chai": "^4.3.11", "@types/chai": "^5.0.1",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.14",
"@types/mocha": "^10.0.6", "@types/mocha": "^10.0.10",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/tmp": "^0.2.6", "@types/tmp": "^0.2.6",
"chai": "^5.0.0", "chai": "^5.1.2",
"get-port": "^7.0.0", "get-port": "^7.1.0",
"image-js": "^0.35.5", "image-js": "^0.35.6",
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^3.0.3", "nodemon": "^3.1.7",
"supertest": "^6.3.4", "supertest": "^7.0.0",
"tmp": "^0.2.1", "tmp": "^0.2.3",
"ts-jest": "^29.1.2", "ts-jest": "^29.2.5",
"ts-mockito": "^2.6.1", "ts-mockito": "^2.6.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"xmldom-ts": "^0.3.1",
"xpath-ts": "^1.3.13" "xpath-ts": "^1.3.13"
}, },
"overrides": { "overrides": {
@@ -66,7 +66,7 @@
"clean": "rm -Rf build node_modules", "clean": "rm -Rf build node_modules",
"build": "tsc", "build": "tsc",
"dev": "BNB_SUBSONIC_CUSTOM_CLIENTS1=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", "dev": "BNB_SUBSONIC_CUSTOM_CLIENTS1=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
"devr": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", "devr": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
"register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534", "register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534",
"test": "jest", "test": "jest",
"testw": "jest --watch", "testw": "jest --watch",

View File

@@ -97,7 +97,7 @@
<xs:complexType> <xs:complexType>
<xs:sequence> <xs:sequence>
<xs:element name="token" type="xs:string"/> <xs:element name="token" type="xs:string"/>
<xs:element name="key" type="xs:string"/> <xs:element name="key" type="xs:string" minOccurs="0"/>
<xs:element name="householdId" type="xs:string"/> <xs:element name="householdId" type="xs:string"/>
</xs:sequence> </xs:sequence>
</xs:complexType> </xs:complexType>
@@ -111,11 +111,12 @@
</xs:simpleType> </xs:simpleType>
</xs:element> </xs:element>
<xs:simpleType name="userAccountType"> <xs:simpleType name="userAccountTier">
<xs:restriction base="xs:string"> <xs:restriction base="xs:string">
<xs:enumeration value="premium"/> <xs:enumeration value="paidPremium"/>
<xs:enumeration value="trial"/> <xs:enumeration value="paidLimited"/>
<xs:enumeration value="free"/> <xs:enumeration value="free"/>
<xs:enumeration value="none"/>
</xs:restriction> </xs:restriction>
</xs:simpleType> </xs:simpleType>
@@ -239,6 +240,12 @@
</xs:simpleContent> </xs:simpleContent>
</xs:complexType> </xs:complexType>
<xs:complexType name="contentKeys">
<xs:sequence>
<xs:element name="contentKey" type="tns:contentKey" maxOccurs="8"/>
</xs:sequence>
</xs:complexType>
<xs:simpleType name="mediaUriAction"> <xs:simpleType name="mediaUriAction">
<xs:restriction base="xs:string"> <xs:restriction base="xs:string">
<xs:enumeration value="IMPLICIT"/> <xs:enumeration value="IMPLICIT"/>
@@ -355,13 +362,11 @@
<xs:complexType name="userInfo"> <xs:complexType name="userInfo">
<xs:sequence> <xs:sequence>
<!-- Everything except userIdHashCode and nickname are for future use --> <!-- accountStatus potentially for future use -->
<xs:element name="userIdHashCode" type="xs:string" minOccurs="1"/> <xs:element name="userIdHashCode" type="xs:string" minOccurs="1"/>
<xs:element name="accountType" type="tns:userAccountType" minOccurs="0"/> <xs:element name="accountTier" type="tns:userAccountTier" minOccurs="0"/>
<xs:element name="accountStatus" type="tns:userAccountStatus" minOccurs="0"/> <xs:element name="accountStatus" type="tns:userAccountStatus" minOccurs="0"/>
<xs:element ref="tns:nickname" minOccurs="0"/> <xs:element ref="tns:nickname" minOccurs="0"/>
<xs:element name="profileUrl" type="tns:sonosUri" minOccurs="0"/>
<xs:element name="pictureUrl" type="tns:sonosUri" minOccurs="0"/>
</xs:sequence> </xs:sequence>
</xs:complexType> </xs:complexType>
@@ -888,7 +893,10 @@
<xs:element name="getMediaURIResult" type="xs:anyURI"/> <xs:element name="getMediaURIResult" type="xs:anyURI"/>
<xs:element name="deviceSessionToken" type="tns:deviceSessionToken" minOccurs="0" maxOccurs="1"/> <xs:element name="deviceSessionToken" type="tns:deviceSessionToken" minOccurs="0" maxOccurs="1"/>
<xs:element name="deviceSessionKey" type="tns:encryptionContext" minOccurs="0" maxOccurs="1"/> <xs:element name="deviceSessionKey" type="tns:encryptionContext" minOccurs="0" maxOccurs="1"/>
<xs:element name="contentKey" type="tns:encryptionContext" minOccurs="0" maxOccurs="1"/> <xs:choice minOccurs="0">
<xs:element name="contentKey" type="tns:encryptionContext" minOccurs="0" maxOccurs="1"/>
<xs:element name="contentKeys" type="tns:contentKeys" minOccurs="0" maxOccurs="1"/>
</xs:choice>
<xs:element name="httpHeaders" type="tns:httpHeaders" minOccurs="0" maxOccurs="1"/> <xs:element name="httpHeaders" type="tns:httpHeaders" minOccurs="0" maxOccurs="1"/>
<xs:element name="uriTimeout" type="xs:int" minOccurs="0" maxOccurs="1"/> <xs:element name="uriTimeout" type="xs:int" minOccurs="0" maxOccurs="1"/>
<xs:element name="positionInformation" type="tns:positionInformation" minOccurs="0" maxOccurs="1"/> <xs:element name="positionInformation" type="tns:positionInformation" minOccurs="0" maxOccurs="1"/>
@@ -2059,7 +2067,7 @@
<wsdl:service name="Sonos"> <wsdl:service name="Sonos">
<wsdl:port name="SonosSoap" binding="tns:SonosSoap"> <wsdl:port name="SonosSoap" binding="tns:SonosSoap">
<soap:address location="/about"/> <soap:address location="http://moapi.sonos.com/Test/TestService.php"/>
</wsdl:port> </wsdl:port>
</wsdl:service> </wsdl:service>

View File

@@ -6,15 +6,16 @@ import logger from "./logger";
import { import {
axiosImageFetcher, axiosImageFetcher,
cachingImageFetcher, cachingImageFetcher,
Subsonic,
TranscodingCustomPlayers, TranscodingCustomPlayers,
NO_CUSTOM_PLAYERS NO_CUSTOM_PLAYERS,
Subsonic
} from "./subsonic"; } from "./subsonic";
import { SubsonicMusicService} from "./subsonic_music_library";
import { InMemoryAPITokens, sha256 } from "./api_tokens"; import { InMemoryAPITokens, sha256 } from "./api_tokens";
import { InMemoryLinkCodes } from "./link_codes"; import { InMemoryLinkCodes } from "./link_codes";
import readConfig from "./config"; import readConfig from "./config";
import sonos, { bonobService } from "./sonos"; import sonos, { bonobService } from "./sonos";
import { MusicService } from "./music_service"; import { MusicService } from "./music_library";
import { SystemClock } from "./clock"; import { SystemClock } from "./clock";
import { JWTSmapiLoginTokens } from "./smapi_auth"; import { JWTSmapiLoginTokens } from "./smapi_auth";
@@ -40,10 +41,13 @@ const artistImageFetcher = config.subsonic.artistImageCache
? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher) ? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher)
: axiosImageFetcher; : axiosImageFetcher;
const subsonic = new Subsonic( const subsonic = new SubsonicMusicService(
config.subsonic.url, new Subsonic(
customPlayers, config.subsonic.url,
artistImageFetcher customPlayers,
artistImageFetcher
),
customPlayers
); );
const featureFlagAwareMusicService: MusicService = { const featureFlagAwareMusicService: MusicService = {

View File

@@ -1,6 +1,8 @@
import _ from "underscore"; import _ from "underscore";
import { createUrnUtil } from "urn-lib"; import { createUrnUtil } from "urn-lib";
import randomstring from "randomstring"; import randomstring from "randomstring";
import { pipe } from "fp-ts/lib/function";
import { either as E } from "fp-ts";
import jwsEncryption from "./encryption"; import jwsEncryption from "./encryption";
@@ -78,7 +80,13 @@ export const parse = (burn: string): BUrn => {
resource: result.resource as string, resource: result.resource as string,
}; };
if(x.system == "encrypted") { if(x.system == "encrypted") {
return parse(encryptor.decrypt(x.resource)); return pipe(
encryptor.decrypt(x.resource),
E.match(
(err) => { throw new Error(err) },
(z) => parse(z)
)
);
} else { } else {
return x; return x;
} }

View File

@@ -4,13 +4,14 @@ import {
randomBytes, randomBytes,
createHash, createHash,
} from "crypto"; } from "crypto";
import { option as O, either as E } from "fp-ts";
import { Either, left, right } from 'fp-ts/Either'
import { pipe } from "fp-ts/lib/function";
import jws from "jws"; import jws from "jws";
const ALGORITHM = "aes-256-cbc"; const ALGORITHM = "aes-256-cbc";
const IV = randomBytes(16); const IV = randomBytes(16);
export type Hash = { export type Hash = {
iv: string; iv: string;
encryptedData: string; encryptedData: string;
@@ -18,7 +19,7 @@ export type Hash = {
export type Encryption = { export type Encryption = {
encrypt: (value: string) => string; encrypt: (value: string) => string;
decrypt: (value: string) => string; decrypt: (value: string) => Either<string, string>;
}; };
export const jwsEncryption = (secret: string): Encryption => { export const jwsEncryption = (secret: string): Encryption => {
@@ -28,7 +29,15 @@ export const jwsEncryption = (secret: string): Encryption => {
payload: value, payload: value,
secret: secret, secret: secret,
}), }),
decrypt: (value: string) => jws.decode(value).payload decrypt: (value: string) => pipe(
jws.decode(value),
O.fromNullable,
O.map(it => it.payload),
O.match(
() => left("Failed to decrypt jws"),
(payload) => right(payload)
)
)
} }
} }
@@ -36,7 +45,8 @@ export const cryptoEncryption = (secret: string): Encryption => {
const key = createHash("sha256") const key = createHash("sha256")
.update(String(secret)) .update(String(secret))
.digest("base64") .digest("base64")
.substr(0, 32); .substring(0, 32);
return { return {
encrypt: (value: string) => { encrypt: (value: string) => {
const cipher = createCipheriv(ALGORITHM, key, IV); const cipher = createCipheriv(ALGORITHM, key, IV);
@@ -45,20 +55,23 @@ export const cryptoEncryption = (secret: string): Encryption => {
cipher.final(), cipher.final(),
]).toString("hex")}`; ]).toString("hex")}`;
}, },
decrypt: (value: string) => { decrypt: (value: string) => pipe(
const parts = value.split("."); right(value),
if(parts.length != 2) throw `Invalid value to decrypt`; E.map(it => it.split(".")),
E.flatMap(it => it.length == 2 ? right({ iv: it[0]!, data: it[1]! }) : left("Invalid value to decrypt")),
const decipher = createDecipheriv( E.map(it => ({
ALGORITHM, hash: it,
key, decipher: createDecipheriv(
Buffer.from(parts[0]!, "hex") ALGORITHM,
); key,
return Buffer.concat([ Buffer.from(it.iv, "hex")
decipher.update(Buffer.from(parts[1]!, "hex")), )
decipher.final(), })),
]).toString(); E.map(it => Buffer.concat([
}, it.decipher.update(Buffer.from(it.hash.data, "hex")),
it.decipher.final(),
]).toString())
),
}; };
}; };

View File

@@ -40,6 +40,7 @@ export type KEY =
| "loginFailed" | "loginFailed"
| "noSonosDevices" | "noSonosDevices"
| "favourites" | "favourites"
| "years"
| "LOVE" | "LOVE"
| "LOVE_SUCCESS" | "LOVE_SUCCESS"
| "STAR" | "STAR"
@@ -83,6 +84,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
loginFailed: "Login failed!", loginFailed: "Login failed!",
noSonosDevices: "No sonos devices", noSonosDevices: "No sonos devices",
favourites: "Favourites", favourites: "Favourites",
years: "Years",
STAR: "Star", STAR: "Star",
UNSTAR: "Un-star", UNSTAR: "Un-star",
STAR_SUCCESS: "Track starred", STAR_SUCCESS: "Track starred",
@@ -125,6 +127,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
loginFailed: "Log på fejlede!", loginFailed: "Log på fejlede!",
noSonosDevices: "Ingen Sonos enheder", noSonosDevices: "Ingen Sonos enheder",
favourites: "Favoritter", favourites: "Favoritter",
years: "Flere år",
STAR: "Tilføj stjerne", STAR: "Tilføj stjerne",
UNSTAR: "Fjern stjerne", UNSTAR: "Fjern stjerne",
STAR_SUCCESS: "Stjerne tilføjet", STAR_SUCCESS: "Stjerne tilføjet",
@@ -167,6 +170,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
loginFailed: "La connexion a échoué !", loginFailed: "La connexion a échoué !",
noSonosDevices: "Aucun appareil Sonos", noSonosDevices: "Aucun appareil Sonos",
favourites: "Favoris", favourites: "Favoris",
years: "Années",
STAR: "Suivre", STAR: "Suivre",
UNSTAR: "Ne plus suivre", UNSTAR: "Ne plus suivre",
STAR_SUCCESS: "Piste suivie", STAR_SUCCESS: "Piste suivie",
@@ -209,6 +213,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
loginFailed: "Inloggen mislukt!", loginFailed: "Inloggen mislukt!",
noSonosDevices: "Geen Sonos-apparaten", noSonosDevices: "Geen Sonos-apparaten",
favourites: "Favorieten", favourites: "Favorieten",
years: "Jaren",
STAR: "Ster ", STAR: "Ster ",
UNSTAR: "Een ster", UNSTAR: "Een ster",
STAR_SUCCESS: "Nummer met ster", STAR_SUCCESS: "Nummer met ster",

View File

@@ -1,4 +1,5 @@
import libxmljs, { Element, Attribute } from "libxmljs2"; import * as xpath from "xpath";
import { DOMParser, Node } from '@xmldom/xmldom';
import _ from "underscore"; import _ from "underscore";
import fs from "fs"; import fs from "fs";
@@ -13,11 +14,10 @@ import {
isMay4, isMay4,
SystemClock, SystemClock,
} from "./clock"; } from "./clock";
import { xmlTidy } from "./utils";
import path from "path"; import path from "path";
const SVG_NS = { const SVG_NS = "http://www.w3.org/2000/svg";
svg: "http://www.w3.org/2000/svg",
};
class ViewBox { class ViewBox {
minX: number; minX: number;
@@ -48,8 +48,16 @@ export type IconFeatures = {
viewPortIncreasePercent: number | undefined; viewPortIncreasePercent: number | undefined;
backgroundColor: string | undefined; backgroundColor: string | undefined;
foregroundColor: string | undefined; foregroundColor: string | undefined;
text: string | undefined;
}; };
export const NO_FEATURES: IconFeatures = {
viewPortIncreasePercent: undefined,
backgroundColor: undefined,
foregroundColor: undefined,
text: undefined
}
export type IconSpec = { export type IconSpec = {
svg: string | undefined; svg: string | undefined;
features: Partial<IconFeatures> | undefined; features: Partial<IconFeatures> | undefined;
@@ -93,17 +101,11 @@ export class SvgIcon implements Icon {
constructor( constructor(
svg: string, svg: string,
features: Partial<IconFeatures> = { features: Partial<IconFeatures> = {}
viewPortIncreasePercent: undefined,
backgroundColor: undefined,
foregroundColor: undefined,
}
) { ) {
this.svg = svg; this.svg = svg;
this.features = { this.features = {
viewPortIncreasePercent: undefined, ...NO_FEATURES,
backgroundColor: undefined,
foregroundColor: undefined,
...features, ...features,
}; };
} }
@@ -117,38 +119,44 @@ export class SvgIcon implements Icon {
}); });
public toString = () => { public toString = () => {
const xml = libxmljs.parseXmlString(this.svg, { const doc = new DOMParser().parseFromString(this.svg, 'text/xml') as unknown as Document;
noblanks: true, const select = xpath.useNamespaces({ svg: SVG_NS });
net: false,
}); const elements = (path: string) => (select(path, doc) as Element[])
const viewBoxAttr = xml.get("//svg:svg/@viewBox", SVG_NS) as Attribute; const element = (path: string) => elements(path)[0]!
let viewBox = new ViewBox(viewBoxAttr.value());
let viewBox = new ViewBox(select("string(//svg:svg/@viewBox)", doc) as string);
if ( if (
this.features.viewPortIncreasePercent && this.features.viewPortIncreasePercent &&
this.features.viewPortIncreasePercent > 0 this.features.viewPortIncreasePercent > 0
) { ) {
viewBox = viewBox.increasePercent(this.features.viewPortIncreasePercent); viewBox = viewBox.increasePercent(this.features.viewPortIncreasePercent);
viewBoxAttr.value(viewBox.toString()); element("//svg:svg").setAttribute("viewBox", viewBox.toString());
} }
if (this.features.backgroundColor) { if(this.features.text) {
(xml.get("//svg:svg/*[1]", SVG_NS) as Element).addPrevSibling( elements("//svg:text").forEach((text) => {
new Element(xml, "rect").attr({ text.textContent = this.features.text!
x: `${viewBox.minX}`,
y: `${viewBox.minY}`,
width: `${Math.abs(viewBox.minX) + viewBox.width}`,
height: `${Math.abs(viewBox.minY) + viewBox.height}`,
fill: this.features.backgroundColor,
})
);
}
if (this.features.foregroundColor) {
(xml.find("//svg:path", SVG_NS) as Element[]).forEach((path) => {
if (path.attr("fill"))
path.attr({ stroke: this.features.foregroundColor! });
else path.attr({ fill: this.features.foregroundColor! });
}); });
} }
return xml.toString(); if (this.features.foregroundColor) {
elements("//svg:path|//svg:text").forEach((path) => {
if (path.getAttribute("fill")) path.setAttribute("stroke", this.features.foregroundColor!);
else path.setAttribute("fill", this.features.foregroundColor!);
});
}
if (this.features.backgroundColor) {
const rect = doc.createElementNS(SVG_NS, "rect");
rect.setAttribute("x", `${viewBox.minX}`);
rect.setAttribute("y", `${viewBox.minY}`);
rect.setAttribute("width", `${Math.abs(viewBox.minX) + viewBox.width}`);
rect.setAttribute("height", `${Math.abs(viewBox.minY) + viewBox.height}`);
rect.setAttribute("fill", this.features.backgroundColor);
const svg = element("//svg:svg")
svg.insertBefore(rect, svg.childNodes[0]!);
}
return xmlTidy(doc as unknown as Node);
}; };
} }
@@ -229,20 +237,24 @@ export type ICON =
| "yoda" | "yoda"
| "heart" | "heart"
| "star" | "star"
| "solidStar"; | "solidStar"
| "yy"
| "yyyy";
const iconFrom = (name: string) => const svgFrom = (name: string) =>
new SvgIcon( new SvgIcon(
fs fs
.readFileSync(path.resolve(__dirname, "..", "web", "icons", name)) .readFileSync(path.resolve(__dirname, "..", "web", "icons", name))
.toString() .toString()
); );
const iconFrom = (name: string) => svgFrom(name).with({ features: { viewPortIncreasePercent: 80 } });
export const ICONS: Record<ICON, SvgIcon> = { export const ICONS: Record<ICON, SvgIcon> = {
artists: iconFrom("navidrome-artists.svg"), artists: iconFrom("navidrome-artists.svg"),
albums: iconFrom("navidrome-all.svg"), albums: iconFrom("navidrome-all.svg"),
radio: iconFrom("navidrome-radio.svg"), radio: iconFrom("navidrome-radio.svg"),
blank: iconFrom("blank.svg"), blank: svgFrom("blank.svg"),
playlists: iconFrom("navidrome-playlists.svg"), playlists: iconFrom("navidrome-playlists.svg"),
genres: iconFrom("Theatre-Mask-111172.svg"), genres: iconFrom("Theatre-Mask-111172.svg"),
random: iconFrom("navidrome-random.svg"), random: iconFrom("navidrome-random.svg"),
@@ -307,7 +319,9 @@ export const ICONS: Record<ICON, SvgIcon> = {
yoda: iconFrom("Yoda-68107.svg"), yoda: iconFrom("Yoda-68107.svg"),
heart: iconFrom("Heart-85038.svg"), heart: iconFrom("Heart-85038.svg"),
star: iconFrom("Star-16101.svg"), star: iconFrom("Star-16101.svg"),
solidStar: iconFrom("Star-43879.svg") solidStar: iconFrom("Star-43879.svg"),
yy: svgFrom("yy.svg"),
yyyy: svgFrom("yyyy.svg"),
}; };
export const STAR_WARS = [ICONS.c3po, ICONS.chewy, ICONS.darth, ICONS.skywalker, ICONS.leia, ICONS.r2d2, ICONS.yoda]; export const STAR_WARS = [ICONS.c3po, ICONS.chewy, ICONS.darth, ICONS.skywalker, ICONS.leia, ICONS.r2d2, ICONS.yoda];

View File

@@ -23,7 +23,8 @@ export type ArtistSummary = {
export type SimilarArtist = ArtistSummary & { inLibrary: boolean }; export type SimilarArtist = ArtistSummary & { inLibrary: boolean };
export type Artist = ArtistSummary & { // todo: maybe is should be artist.summary rather than an artist also being a summary?
export type Artist = Pick<ArtistSummary, "id" | "name" | "image"> & {
albums: AlbumSummary[]; albums: AlbumSummary[];
similarArtists: SimilarArtist[] similarArtists: SimilarArtist[]
}; };
@@ -34,18 +35,21 @@ export type AlbumSummary = {
year: string | undefined; year: string | undefined;
genre: Genre | undefined; genre: Genre | undefined;
coverArt: BUrn | undefined; coverArt: BUrn | undefined;
artistName: string | undefined; artistName: string | undefined;
artistId: string | undefined; artistId: string | undefined;
}; };
export type Album = AlbumSummary & {}; export type Album = Pick<AlbumSummary, "id" | "name" | "year" | "genre" | "coverArt" | "artistName" | "artistId"> & { tracks: Track[] };
export type Genre = { export type Genre = {
name: string; name: string;
id: string; id: string;
} }
export type Year = {
year: string;
}
export type Rating = { export type Rating = {
love: boolean; love: boolean;
stars: number; stars: number;
@@ -56,7 +60,7 @@ export type Encoding = {
mimeType: string mimeType: string
} }
export type Track = { export type TrackSummary = {
id: string; id: string;
name: string; name: string;
encoding: Encoding, encoding: Encoding,
@@ -64,9 +68,12 @@ export type Track = {
number: number | undefined; number: number | undefined;
genre: Genre | undefined; genre: Genre | undefined;
coverArt: BUrn | undefined; coverArt: BUrn | undefined;
album: AlbumSummary;
artist: ArtistSummary; artist: ArtistSummary;
rating: Rating; rating: Rating;
}
export type Track = TrackSummary & {
album: AlbumSummary;
}; };
export type RadioStation = { export type RadioStation = {
@@ -100,11 +107,13 @@ export const asResult = <T>([results, total]: [T[], number]) => ({
export type ArtistQuery = Paging; export type ArtistQuery = Paging;
export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'random' | 'recentlyPlayed' | 'mostPlayed' | 'recentlyAdded' | 'favourited' | 'starred'; export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'byYear' | 'random' | 'recentlyPlayed' | 'mostPlayed' | 'recentlyAdded' | 'favourited' | 'starred';
export type AlbumQuery = Paging & { export type AlbumQuery = Paging & {
type: AlbumQueryType; type: AlbumQueryType;
genre?: string; genre?: string;
fromYear?: string;
toYear?: string;
}; };
export const artistToArtistSummary = (it: Artist): ArtistSummary => ({ export const artistToArtistSummary = (it: Artist): ArtistSummary => ({
@@ -123,6 +132,18 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
coverArt: it.coverArt coverArt: it.coverArt
}); });
export const trackToTrackSummary = (it: Track): TrackSummary => ({
id: it.id,
name: it.name,
encoding: it.encoding,
duration: it.duration,
number: it.number,
genre: it.genre,
coverArt: it.coverArt,
artist: it.artist,
rating: it.rating
});
export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({ export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({
id: it.id, id: it.id,
name: it.name, name: it.name,
@@ -170,9 +191,9 @@ export interface MusicLibrary {
artist(id: string): Promise<Artist>; artist(id: string): Promise<Artist>;
albums(q: AlbumQuery): Promise<Result<AlbumSummary>>; albums(q: AlbumQuery): Promise<Result<AlbumSummary>>;
album(id: string): Promise<Album>; album(id: string): Promise<Album>;
tracks(albumId: string): Promise<Track[]>;
track(trackId: string): Promise<Track>; track(trackId: string): Promise<Track>;
genres(): Promise<Genre[]>; genres(): Promise<Genre[]>;
years(): Promise<Year[]>;
stream({ stream({
trackId, trackId,
range, range,
@@ -193,8 +214,8 @@ export interface MusicLibrary {
deletePlaylist(id: string): Promise<boolean> deletePlaylist(id: string): Promise<boolean>
addToPlaylist(playlistId: string, trackId: string): Promise<boolean> addToPlaylist(playlistId: string, trackId: string): Promise<boolean>
removeFromPlaylist(playlistId: string, indicies: number[]): Promise<boolean> removeFromPlaylist(playlistId: string, indicies: number[]): Promise<boolean>
similarSongs(id: string): Promise<Track[]>; similarSongs(id: string): Promise<TrackSummary[]>;
topSongs(artistId: string): Promise<Track[]>; topSongs(artistId: string): Promise<TrackSummary[]>;
radioStation(id: string): Promise<RadioStation> radioStation(id: string): Promise<RadioStation>
radioStations(): Promise<RadioStation[]> radioStations(): Promise<RadioStation[]>
} }

View File

@@ -22,7 +22,7 @@ import {
ratingAsInt, ratingAsInt,
} from "./smapi"; } from "./smapi";
import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
import { MusicService, AuthFailure, AuthSuccess } from "./music_service"; import { MusicService, AuthFailure, AuthSuccess } from "./music_library";
import bindSmapiSoapServiceToExpress from "./smapi"; import bindSmapiSoapServiceToExpress from "./smapi";
import { APITokens, InMemoryAPITokens } from "./api_tokens"; import { APITokens, InMemoryAPITokens } from "./api_tokens";
import logger from "./logger"; import logger from "./logger";
@@ -498,16 +498,18 @@ function server(
} }
}); });
app.get("/icon/:type/size/:size", (req, res) => { app.get("/icon/:type_text/size/:size", (req, res) => {
const type = req.params["type"]!; const match = (req.params["type_text"] || "")!.match("^([A-Za-z0-9]+)(?:\:([A-Za-z0-9]+))?$")
if (!match)
return res.status(400).send();
const type = match[1]!
const text = match[2]
const size = req.params["size"]!; const size = req.params["size"]!;
if (!Object.keys(ICONS).includes(type)) { if (!Object.keys(ICONS).includes(type)) {
return res.status(404).send(); return res.status(404).send();
} else if ( } else if (size != "legacy" && !SONOS_RECOMMENDED_IMAGE_SIZES.includes(size)) {
size != "legacy" &&
!SONOS_RECOMMENDED_IMAGE_SIZES.includes(size)
) {
return res.status(400).send(); return res.status(400).send();
} else { } else {
let icon = (ICONS as any)[type]! as Icon; let icon = (ICONS as any)[type]! as Icon;
@@ -528,8 +530,8 @@ function server(
icon icon
.apply( .apply(
features({ features({
viewPortIncreasePercent: 80,
...serverOpts.iconColors, ...serverOpts.iconColors,
text: text
}) })
) )
.apply(festivals(clock)) .apply(festivals(clock))

View File

@@ -10,18 +10,18 @@ import logger from "./logger";
import { LinkCodes } from "./link_codes"; import { LinkCodes } from "./link_codes";
import { import {
Album,
AlbumQuery, AlbumQuery,
AlbumSummary, AlbumSummary,
ArtistSummary, ArtistSummary,
Genre, Genre,
Year,
MusicService, MusicService,
Playlist, Playlist,
RadioStation, RadioStation,
Rating, Rating,
slice2, slice2,
Track, Track,
} from "./music_service"; } from "./music_library";
import { APITokens } from "./api_tokens"; import { APITokens } from "./api_tokens";
import { Clock } from "./clock"; import { Clock } from "./clock";
import { URLBuilder } from "./url_builder"; import { URLBuilder } from "./url_builder";
@@ -61,7 +61,7 @@ export const SONOS_RECOMMENDED_IMAGE_SIZES = [
const WSDL_FILE = path.resolve( const WSDL_FILE = path.resolve(
__dirname, __dirname,
"Sonoswsdl-1.19.4-20190411.142401-3.wsdl" "Sonoswsdl-1.19.6-20231024.wsdl"
); );
export type Credentials = { export type Credentials = {
@@ -244,12 +244,20 @@ export type Container = {
}; };
const genre = (bonobUrl: URLBuilder, genre: Genre) => ({ const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
itemType: "container", itemType: "albumList",
id: `genre:${genre.id}`, id: `genre:${genre.id}`,
title: genre.name, title: genre.name,
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(), albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(),
}); });
const yyyy = (bonobUrl: URLBuilder, year: Year) => ({
itemType: "albumList",
id: `year:${year.year}`,
title: year.year,
// todo: maybe year.year should be nullable?
albumArtURI: year.year !== "?" ? iconArtURI(bonobUrl, "yyyy", year.year).href() : iconArtURI(bonobUrl, "music").href(),
});
const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({ const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
itemType: "playlist", itemType: "playlist",
id: `playlist:${playlist.id}`, id: `playlist:${playlist.id}`,
@@ -278,9 +286,9 @@ export const coverArtURI = (
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl")) O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
); );
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) => export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON, text: string | undefined = undefined) =>
bonobUrl.append({ bonobUrl.append({
pathname: `/icon/${icon}/size/legacy`, pathname: `/icon/${text == undefined ? icon : `${icon}:${text}`}/size/legacy`,
}); });
export const sonosifyMimeType = (mimeType: string) => export const sonosifyMimeType = (mimeType: string) =>
@@ -603,7 +611,7 @@ function bindSmapiSoapServiceToExpress(
switch (type) { switch (type) {
case "artist": case "artist":
return musicLibrary.artist(typeId).then((artist) => { return musicLibrary.artist(typeId).then((artist) => {
const [page, total] = slice2<Album>(paging)( const [page, total] = slice2<AlbumSummary>(paging)(
artist.albums artist.albums
); );
return { return {
@@ -740,6 +748,12 @@ function bindSmapiSoapServiceToExpress(
albumArtURI: iconArtURI(bonobUrl, "genres").href(), albumArtURI: iconArtURI(bonobUrl, "genres").href(),
itemType: "container", itemType: "container",
}, },
{
id: "years",
title: lang("years"),
albumArtURI: iconArtURI(bonobUrl, "music").href(),
itemType: "container",
},
{ {
id: "recentlyAdded", id: "recentlyAdded",
title: lang("recentlyAdded"), title: lang("recentlyAdded"),
@@ -817,6 +831,13 @@ function bindSmapiSoapServiceToExpress(
genre: typeId, genre: typeId,
...paging, ...paging,
}); });
case "year":
return albums({
type: "byYear",
fromYear: typeId,
toYear: typeId,
...paging,
});
case "randomAlbums": case "randomAlbums":
return albums({ return albums({
type: "random", type: "random",
@@ -860,6 +881,19 @@ function bindSmapiSoapServiceToExpress(
total, total,
}) })
); );
case "years":
return musicLibrary
.years()
.then(slice2(paging))
.then(([page, total]) =>
getMetadataResult({
mediaCollection: page.map((it) =>
yyyy(bonobUrl, it)
),
index: paging._index,
total,
})
);
case "genres": case "genres":
return musicLibrary return musicLibrary
.genres() .genres()
@@ -947,7 +981,8 @@ function bindSmapiSoapServiceToExpress(
}); });
case "album": case "album":
return musicLibrary return musicLibrary
.tracks(typeId!) .album(typeId!)
.then(it => it.tracks)
.then(slice2(paging)) .then(slice2(paging))
.then(([page, total]) => { .then(([page, total]) => {
return getMetadataResult({ return getMetadataResult({

View File

@@ -5,24 +5,18 @@ import { pipe } from "fp-ts/lib/function";
import { Md5 } from "ts-md5"; import { Md5 } from "ts-md5";
import { import {
Credentials, Credentials,
MusicService,
Album, Album,
Result,
slice2,
AlbumQuery, AlbumQuery,
ArtistQuery,
MusicLibrary,
AlbumSummary, AlbumSummary,
Genre, Genre,
Track, Track,
CoverArt, CoverArt,
Rating,
AlbumQueryType, AlbumQueryType,
Artist,
AuthFailure,
PlaylistSummary,
Encoding, Encoding,
} from "./music_service"; albumToAlbumSummary,
TrackSummary,
AuthFailure
} from "./music_library";
import sharp from "sharp"; import sharp from "sharp";
import _ from "underscore"; import _ from "underscore";
import fse from "fs-extra"; import fse from "fs-extra";
@@ -31,9 +25,8 @@ import path from "path";
import axios, { AxiosRequestConfig } from "axios"; import axios, { AxiosRequestConfig } from "axios";
import randomstring from "randomstring"; import randomstring from "randomstring";
import { b64Encode, b64Decode } from "./b64"; import { b64Encode, b64Decode } from "./b64";
import logger from "./logger"; import { BUrn } from "./burn";
import { assertSystem, BUrn } from "./burn"; import { album, artist } from "./smapi";
import { artist } from "./smapi";
import { URLBuilder } from "./url_builder"; import { URLBuilder } from "./url_builder";
export const BROWSER_HEADERS = { export const BROWSER_HEADERS = {
@@ -108,7 +101,7 @@ type genre = {
value: string; value: string;
}; };
type GetGenresResponse = SubsonicResponse & { export type GetGenresResponse = SubsonicResponse & {
genres: { genres: {
genre: genre[]; genre: genre[];
}; };
@@ -168,57 +161,70 @@ export type song = {
transcodedContentType: string | undefined; transcodedContentType: string | undefined;
type: string | undefined; type: string | undefined;
userRating: number | undefined; userRating: number | undefined;
// todo: this field shouldnt be on song?
starred: string | undefined; starred: string | undefined;
}; };
type GetAlbumResponse = { export type GetAlbumResponse = {
album: album & { album: album & {
song: song[]; song: song[];
}; };
}; };
type playlist = { export type GetPlaylistResponse = {
id: string;
name: string;
coverArt: string | undefined;
};
type GetPlaylistResponse = {
// todo: isnt the type here a composite? playlistSummary && { entry: song[]; } // todo: isnt the type here a composite? playlistSummary && { entry: song[]; }
playlist: { playlist: {
id: string; id: string;
name: string; name: string;
coverArt: string | undefined;
entry: song[]; entry: song[];
// todo: this is an ND specific field?
coverArt: string | undefined;
}; };
}; };
type GetPlaylistsResponse = { export type GetPlaylistsResponse = {
playlists: { playlist: playlist[] }; playlists: {
playlist: {
id: string;
name: string;
//owner: string,
//public: boolean,
//created: string,
//changed: string,
//songCount: int,
//duration: int,
// todo: this is an ND specific field.
coverArt: string | undefined;
}[]
};
}; };
type GetSimilarSongsResponse = { export type GetSimilarSongsResponse = {
similarSongs2: { song: song[] }; similarSongs2: { song: song[] };
}; };
type GetTopSongsResponse = { export type GetTopSongsResponse = {
topSongs: { song: song[] }; topSongs: { song: song[] };
}; };
type GetInternetRadioStationsResponse = { export type GetInternetRadioStationsResponse = {
internetRadioStations: { internetRadioStation: { internetRadioStations: {
id: string, internetRadioStation: {
name: string, id: string;
streamUrl: string, name: string;
homePageUrl?: string }[] streamUrl: string;
} homePageUrl?: string;
} }[];
};
};
type GetSongResponse = { export type GetSongResponse = {
song: song; song: song;
}; };
type GetStarredResponse = { export type GetStarredResponse = {
starred2: { starred2: {
song: song[]; song: song[];
album: album[]; album: album[];
@@ -232,7 +238,7 @@ export type PingResponse = {
serverVersion: string; serverVersion: string;
}; };
type Search3Response = SubsonicResponse & { export type Search3Response = SubsonicResponse & {
searchResult3: { searchResult3: {
artist: artist[]; artist: artist[];
album: album[]; album: album[];
@@ -246,12 +252,12 @@ export function isError(
return (subsonicResponse as SubsonicError).error !== undefined; return (subsonicResponse as SubsonicError).error !== undefined;
} }
type IdName = { export type IdName = {
id: string; id: string;
name: string; name: string;
}; };
const coverArtURN = (coverArt: string | undefined): BUrn | undefined => export const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
pipe( pipe(
coverArt, coverArt,
O.fromNullable, O.fromNullable,
@@ -285,21 +291,25 @@ export const artistImageURN = (
} }
}; };
export const asTrack = (album: Album, song: song, customPlayers: CustomPlayers): Track => ({ export const asTrackSummary = (
song: song,
customPlayers: CustomPlayers
): TrackSummary => ({
id: song.id, id: song.id,
name: song.title, name: song.title,
encoding: pipe( encoding: pipe(
customPlayers.encodingFor({ mimeType: song.contentType }), customPlayers.encodingFor({ mimeType: song.contentType }),
O.getOrElse(() => ({ O.getOrElse(() => ({
player: DEFAULT_CLIENT_APPLICATION, player: DEFAULT_CLIENT_APPLICATION,
mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType mimeType: song.transcodedContentType
? song.transcodedContentType
: song.contentType,
})) }))
), ),
duration: song.duration || 0, duration: song.duration || 0,
number: song.track || 0, number: song.track || 0,
genre: maybeAsGenre(song.genre), genre: maybeAsGenre(song.genre),
coverArt: coverArtURN(song.coverArt), coverArt: coverArtURN(song.coverArt),
album,
artist: { artist: {
id: song.artistId, id: song.artistId,
name: song.artist ? song.artist : "?", name: song.artist ? song.artist : "?",
@@ -316,7 +326,16 @@ export const asTrack = (album: Album, song: song, customPlayers: CustomPlayers):
}, },
}); });
const asAlbum = (album: album): Album => ({ export const asTrack = (
album: AlbumSummary,
song: song,
customPlayers: CustomPlayers
): Track => ({
...asTrackSummary(song, customPlayers),
album: album,
});
export const asAlbumSummary = (album: album): AlbumSummary => ({
id: album.id, id: album.id,
name: album.name, name: album.name,
year: album.year, year: album.year,
@@ -326,19 +345,14 @@ const asAlbum = (album: album): Album => ({
coverArt: coverArtURN(album.coverArt), coverArt: coverArtURN(album.coverArt),
}); });
// coverArtURN
const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
});
export const asGenre = (genreName: string) => ({ export const asGenre = (genreName: string) => ({
id: b64Encode(genreName), id: b64Encode(genreName),
name: genreName, name: genreName,
}); });
const maybeAsGenre = (genreName: string | undefined): Genre | undefined => export const maybeAsGenre = (
genreName: string | undefined
): Genre | undefined =>
pipe( pipe(
genreName, genreName,
O.fromNullable, O.fromNullable,
@@ -346,8 +360,12 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
O.getOrElseW(() => undefined) O.getOrElseW(() => undefined)
); );
export const asYear = (year: string) => ({
year: year,
});
export interface CustomPlayers { export interface CustomPlayers {
encodingFor({ mimeType }: { mimeType: string }): O.Option<Encoding> encodingFor({ mimeType }: { mimeType: string }): O.Option<Encoding>;
} }
export type CustomClient = { export type CustomClient = {
@@ -374,24 +392,25 @@ export class TranscodingCustomPlayers implements CustomPlayers {
return new TranscodingCustomPlayers(new Map(parts)); return new TranscodingCustomPlayers(new Map(parts));
} }
encodingFor = ({ mimeType }: { mimeType: string }): O.Option<Encoding> => pipe( encodingFor = ({ mimeType }: { mimeType: string }): O.Option<Encoding> =>
this.transcodings.get(mimeType), pipe(
O.fromNullable, this.transcodings.get(mimeType),
O.map(transcodedMimeType => ({ O.fromNullable,
player:`${DEFAULT_CLIENT_APPLICATION}+${mimeType}`, O.map((transcodedMimeType) => ({
mimeType: transcodedMimeType player: `${DEFAULT_CLIENT_APPLICATION}+${mimeType}`,
})) mimeType: transcodedMimeType,
) }))
);
} }
export const NO_CUSTOM_PLAYERS: CustomPlayers = { export const NO_CUSTOM_PLAYERS: CustomPlayers = {
encodingFor(_) { encodingFor(_) {
return O.none return O.none;
}, },
} };
const DEFAULT_CLIENT_APPLICATION = "bonob"; export const DEFAULT_CLIENT_APPLICATION = "bonob";
const USER_AGENT = "bonob"; export const USER_AGENT = "bonob";
export const asURLSearchParams = (q: any) => { export const asURLSearchParams = (q: any) => {
const urlSearchParams = new URLSearchParams(); const urlSearchParams = new URLSearchParams();
@@ -406,7 +425,7 @@ export const asURLSearchParams = (q: any) => {
export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>; export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>;
export const cachingImageFetcher = export const cachingImageFetcher =
(cacheDir: string, delegate: ImageFetcher) => (cacheDir: string, delegate: ImageFetcher, makeSharp = sharp) =>
async (url: string): Promise<CoverArt | undefined> => { async (url: string): Promise<CoverArt | undefined> => {
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
return fse return fse
@@ -415,7 +434,7 @@ export const cachingImageFetcher =
.catch(() => .catch(() =>
delegate(url).then((image) => { delegate(url).then((image) => {
if (image) { if (image) {
return sharp(image.data) return makeSharp(image.data)
.png() .png()
.toBuffer() .toBuffer()
.then((png) => { .then((png) => {
@@ -446,6 +465,7 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
alphabeticalByArtist: "alphabeticalByArtist", alphabeticalByArtist: "alphabeticalByArtist",
alphabeticalByName: "alphabeticalByName", alphabeticalByName: "alphabeticalByName",
byGenre: "byGenre", byGenre: "byGenre",
byYear: "byYear",
random: "random", random: "random",
recentlyPlayed: "recent", recentlyPlayed: "recent",
mostPlayed: "frequent", mostPlayed: "frequent",
@@ -464,17 +484,11 @@ type SubsonicCredentials = Credentials & {
export const asToken = (credentials: SubsonicCredentials) => export const asToken = (credentials: SubsonicCredentials) =>
b64Encode(JSON.stringify(credentials)); b64Encode(JSON.stringify(credentials));
export const parseToken = (token: string): SubsonicCredentials => export const parseToken = (token: string): SubsonicCredentials =>
JSON.parse(b64Decode(token)); JSON.parse(b64Decode(token));
interface SubsonicMusicLibrary extends MusicLibrary { export class Subsonic {
flavour(): string;
bearerToken(
credentials: Credentials
): TE.TaskEither<Error, string | undefined>;
}
export class Subsonic implements MusicService {
url: URLBuilder; url: URLBuilder;
customPlayers: CustomPlayers; customPlayers: CustomPlayers;
externalImageFetcher: ImageFetcher; externalImageFetcher: ImageFetcher;
@@ -489,7 +503,7 @@ export class Subsonic implements MusicService {
this.externalImageFetcher = externalImageFetcher; this.externalImageFetcher = externalImageFetcher;
} }
get = async ( private get = async (
{ username, password }: Credentials, { username, password }: Credentials,
path: string, path: string,
q: {} = {}, q: {} = {},
@@ -518,7 +532,9 @@ export class Subsonic implements MusicService {
} else return response; } else return response;
}); });
getJSON = async <T>( // todo: should I put a catch in here and force a subsonic fail status?
// or there is a catch above, that then throws, perhaps can go in there?
private getJSON = async <T>(
{ username, password }: Credentials, { username, password }: Credentials,
path: string, path: string,
q: {} = {} q: {} = {}
@@ -531,40 +547,16 @@ export class Subsonic implements MusicService {
else return json as unknown as T; else return json as unknown as T;
}); });
generateToken = (credentials: Credentials) => ping = (credentials: Credentials): TE.TaskEither<AuthFailure, { authenticated: Boolean, type: string}> =>
pipe( TE.tryCatch(
TE.tryCatch( () => this.getJSON<PingResponse>(credentials, "/rest/ping.view")
() => .then(it => ({
this.getJSON<PingResponse>( authenticated: it.status == "ok",
_.pick(credentials, "username", "password"), type: it.type
"/rest/ping.view" })),
), (e) => new AuthFailure(e as string)
(e) => new AuthFailure(e as string) )
),
TE.chain(({ type }) =>
pipe(
TE.tryCatch(
() => this.libraryFor({ ...credentials, type }),
() => new AuthFailure("Failed to get library")
),
TE.map((library) => ({ type, library }))
)
),
TE.chain(({ library, type }) =>
pipe(
library.bearerToken(credentials),
TE.map((bearer) => ({ bearer, type }))
)
),
TE.map(({ bearer, type }) => ({
serviceToken: asToken({ ...credentials, bearer, type }),
userId: credentials.username,
nickname: credentials.username,
}))
);
refreshToken = (serviceToken: string) =>
this.generateToken(parseToken(serviceToken));
getArtists = ( getArtists = (
credentials: Credentials credentials: Credentials
@@ -583,6 +575,7 @@ export class Subsonic implements MusicService {
})) }))
); );
// todo: should be getArtistInfo2?
getArtistInfo = ( getArtistInfo = (
credentials: Credentials, credentials: Credentials,
id: string id: string
@@ -606,30 +599,43 @@ export class Subsonic implements MusicService {
m: it.mediumImageUrl, m: it.mediumImageUrl,
l: it.largeImageUrl, l: it.largeImageUrl,
}, },
//todo: this does seem to be in OpenSubsonic?? it is also singular
similarArtist: (it.similarArtist || []).map((artist) => ({ similarArtist: (it.similarArtist || []).map((artist) => ({
id: `${artist.id}`, id: `${artist.id}`,
name: artist.name, name: artist.name,
// todo: whats this inLibrary used for? it probably should be filtered on??
inLibrary: artistIsInLibrary(artist.id), inLibrary: artistIsInLibrary(artist.id),
image: artistImageURN({ image: artistImageURN({
artistId: artist.id, artistId: artist.id,
artistImageURL: artist.artistImageUrl, artistImageURL: artist.artistImageUrl,
}), }),
})), })),
})); })
);
getAlbum = (credentials: Credentials, id: string): Promise<Album> => getAlbum = (credentials: Credentials, id: string): Promise<Album> =>
this.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", { id }) this.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", { id })
.then((it) => it.album) .then((it) => it.album)
.then((album) => ({ .then((album) => {
id: album.id, const x: AlbumSummary = {
name: album.name, id: album.id,
year: album.year, name: album.name,
genre: maybeAsGenre(album.genre), year: album.year,
artistId: album.artistId, genre: maybeAsGenre(album.genre),
artistName: album.artist, artistId: album.artistId,
coverArt: coverArtURN(album.coverArt), artistName: album.artist,
})); coverArt: coverArtURN(album.coverArt)
}
return { summary: x, songs: album.song }
}).then(({ summary, songs }) => {
const x: AlbumSummary = summary
const y: Track[] = songs.map((it) => asTrack(summary, it, this.customPlayers))
return {
...x,
tracks: y
};
});
getArtist = ( getArtist = (
credentials: Credentials, credentials: Credentials,
id: string id: string
@@ -647,26 +653,6 @@ export class Subsonic implements MusicService {
albums: this.toAlbumSummary(it.album || []), albums: this.toAlbumSummary(it.album || []),
})); }));
getArtistWithInfo = (credentials: Credentials, id: string) =>
Promise.all([
this.getArtist(credentials, id),
this.getArtistInfo(credentials, id),
]).then(([artist, artistInfo]) => ({
id: artist.id,
name: artist.name,
image: artistImageURN({
artistId: artist.id,
artistImageURL: [
artist.artistImageUrl,
artistInfo.images.l,
artistInfo.images.m,
artistInfo.images.s,
].find(isValidImage),
}),
albums: artist.albums,
similarArtists: artistInfo.similarArtist,
}));
getCoverArt = (credentials: Credentials, id: string, size?: number) => getCoverArt = (credentials: Credentials, id: string, size?: number) =>
this.get(credentials, "/rest/getCoverArt", size ? { id, size } : { id }, { this.get(credentials, "/rest/getCoverArt", size ? { id, size } : { id }, {
headers: { "User-Agent": "bonob" }, headers: { "User-Agent": "bonob" },
@@ -680,7 +666,7 @@ export class Subsonic implements MusicService {
.then((it) => it.song) .then((it) => it.song)
.then((song) => .then((song) =>
this.getAlbum(credentials, song.albumId!).then((album) => this.getAlbum(credentials, song.albumId!).then((album) =>
asTrack(album, song, this.customPlayers) asTrack(albumToAlbumSummary(album), song, this.customPlayers)
) )
); );
@@ -720,6 +706,8 @@ export class Subsonic implements MusicService {
this.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", { this.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
type: AlbumQueryTypeToSubsonicType[q.type], type: AlbumQueryTypeToSubsonicType[q.type],
...(q.genre ? { genre: b64Decode(q.genre) } : {}), ...(q.genre ? { genre: b64Decode(q.genre) } : {}),
...(q.fromYear ? { fromYear: q.fromYear } : {}),
...(q.toYear ? { toYear: q.toYear } : {}),
size: 500, size: 500,
offset: q._index, offset: q._index,
}) })
@@ -730,336 +718,176 @@ export class Subsonic implements MusicService {
total: albums.length == 500 ? total : q._index + albums.length, total: albums.length == 500 ? total : q._index + albums.length,
})); }));
// getStarred2 = (credentials: Credentials): Promise<{ albums: Album[] }> => getGenres = (credentials: Credentials) =>
// this.getJSON<GetStarredResponse>(credentials, "/rest/getStarred2") this.getJSON<GetGenresResponse>(credentials, "/rest/getGenres").then((it) =>
// .then((it) => it.starred2) pipe(
// .then((it) => ({ it.genres.genre || [],
// albums: it.album.map(asAlbum), A.filter((it) => it.albumCount > 0),
// })); A.map((it) => it.value),
A.sort(ordString),
A.map(maybeAsGenre),
A.filter((it) => it != undefined)
)
);
login = async (token: string) => this.libraryFor(parseToken(token)); private st4r = (credentials: Credentials, action: string, { id } : { id: string }) =>
this.getJSON<SubsonicResponse>(credentials, `/rest/${action}`, { id }).then(it =>
it.status == "ok"
);
private libraryFor = ( star = (credentials: Credentials, ids : { id: string }) =>
credentials: Credentials & { type: string } this.st4r(credentials, "star", ids)
): Promise<SubsonicMusicLibrary> => {
const subsonic = this;
const genericSubsonic: SubsonicMusicLibrary = { unstar = (credentials: Credentials, ids : { id: string }) =>
flavour: () => "subsonic", this.st4r(credentials, "unstar", ids)
bearerToken: (_: Credentials) => TE.right(undefined),
artists: (q: ArtistQuery): Promise<Result<ArtistSummary>> => setRating = (credentials: Credentials, id: string, rating: number) =>
subsonic this.getJSON<SubsonicResponse>(credentials, `/rest/setRating`, {
.getArtists(credentials) id,
.then(slice2(q)) rating,
.then(([page, total]) => ({ })
total, .then(it => it.status == "ok");
results: page.map((it) => ({
id: it.id, scrobble = (credentials: Credentials, id: string, submission: boolean) =>
name: it.name, this.getJSON<SubsonicResponse>(credentials, `/rest/scrobble`, {
image: it.image, id,
})), submission,
})
.then(it => it.status == "ok")
stream = (credentials: Credentials, id: string, c: string, range: string | undefined) =>
this.get(
credentials,
`/rest/stream`,
{
id,
c,
},
{
headers: pipe(
range,
O.fromNullable,
O.map((range) => ({
"User-Agent": USER_AGENT,
Range: range,
})), })),
artist: async (id: string): Promise<Artist> => O.getOrElse(() => ({
subsonic.getArtistWithInfo(credentials, id), "User-Agent": USER_AGENT,
albums: async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
subsonic.getAlbumList2(credentials, q),
album: (id: string): Promise<Album> => subsonic.getAlbum(credentials, id),
genres: () =>
subsonic
.getJSON<GetGenresResponse>(credentials, "/rest/getGenres")
.then((it) =>
pipe(
it.genres.genre || [],
A.filter((it) => it.albumCount > 0),
A.map((it) => it.value),
A.sort(ordString),
A.map((it) => ({ id: b64Encode(it), name: it }))
)
),
tracks: (albumId: string) =>
subsonic
.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", {
id: albumId,
})
.then((it) => it.album)
.then((album) =>
(album.song || []).map((song) => asTrack(asAlbum(album), song, this.customPlayers))
),
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,
}: {
trackId: string;
range: string | undefined;
}) =>
subsonic.getTrack(credentials, trackId).then((track) =>
subsonic
.get(
credentials,
`/rest/stream`,
{
id: trackId,
c: track.encoding.player,
},
{
headers: pipe(
range,
O.fromNullable,
O.map((range) => ({
"User-Agent": USER_AGENT,
Range: range,
})),
O.getOrElse(() => ({
"User-Agent": USER_AGENT,
}))
),
responseType: "stream",
}
)
.then((stream) => ({
status: stream.status,
headers: {
"content-type": stream.headers["content-type"],
"content-length": stream.headers["content-length"],
"content-range": stream.headers["content-range"],
"accept-ranges": stream.headers["accept-ranges"],
},
stream: stream.data,
}))
),
coverArt: async (coverArtURN: BUrn, size?: number) =>
Promise.resolve(coverArtURN)
.then((it) => assertSystem(it, "subsonic"))
.then((it) => it.resource.split(":")[1]!)
.then((it) => subsonic.getCoverArt(credentials, it, size))
.then((res) => ({
contentType: res.headers["content-type"],
data: Buffer.from(res.data, "binary"),
})) }))
.catch((e) => {
logger.error(
`Failed getting coverArt for urn:'${coverArtURN}': ${e}`
);
return undefined;
}),
scrobble: async (id: string) =>
subsonic
.getJSON(credentials, `/rest/scrobble`, {
id,
submission: true,
})
.then((_) => true)
.catch(() => false),
nowPlaying: async (id: string) =>
subsonic
.getJSON(credentials, `/rest/scrobble`, {
id,
submission: false,
})
.then((_) => true)
.catch(() => false),
searchArtists: async (query: string) =>
subsonic
.search3(credentials, { query, artistCount: 20 })
.then(({ artists }) =>
artists.map((artist) => ({
id: artist.id,
name: artist.name,
image: artistImageURN({
artistId: artist.id,
artistImageURL: artist.artistImageUrl,
}),
}))
),
searchAlbums: async (query: string) =>
subsonic
.search3(credentials, { query, albumCount: 20 })
.then(({ albums }) => subsonic.toAlbumSummary(albums)),
searchTracks: async (query: string) =>
subsonic
.search3(credentials, { query, songCount: 20 })
.then(({ songs }) =>
Promise.all(
songs.map((it) => subsonic.getTrack(credentials, it.id))
)
),
playlists: async () =>
subsonic
.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
.then((it) => it.playlists.playlist || [])
.then((playlists) => playlists.map(asPlayListSummary)),
playlist: async (id: string) =>
subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", {
id,
})
.then((it) => it.playlist)
.then((playlist) => {
let trackNumber = 1;
return {
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
entries: (playlist.entry || []).map((entry) => ({
...asTrack(
{
id: entry.albumId!,
name: entry.album!,
year: entry.year,
genre: maybeAsGenre(entry.genre),
artistName: entry.artist,
artistId: entry.artistId,
coverArt: coverArtURN(entry.coverArt),
},
entry,
this.customPlayers
),
number: trackNumber++,
})),
};
}),
createPlaylist: async (name: string) =>
subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/createPlaylist", {
name,
})
.then((it) => it.playlist)
// todo: why is this line so similar to other playlist lines??
.then((it) => ({
id: it.id,
name: it.name,
coverArt: coverArtURN(it.coverArt),
})),
deletePlaylist: async (id: string) =>
subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", {
id,
})
.then((_) => true),
addToPlaylist: async (playlistId: string, trackId: string) =>
subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", {
playlistId,
songIdToAdd: trackId,
})
.then((_) => true),
removeFromPlaylist: async (playlistId: string, indicies: number[]) =>
subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", {
playlistId,
songIndexToRemove: indicies,
})
.then((_) => true),
similarSongs: async (id: string) =>
subsonic
.getJSON<GetSimilarSongsResponse>(
credentials,
"/rest/getSimilarSongs2",
{ id, count: 50 }
)
.then((it) => it.similarSongs2.song || [])
.then((songs) =>
Promise.all(
songs.map((song) =>
subsonic
.getAlbum(credentials, song.albumId!)
.then((album) => asTrack(album, song, this.customPlayers))
)
)
),
topSongs: async (artistId: string) =>
subsonic.getArtist(credentials, artistId).then(({ name }) =>
subsonic
.getJSON<GetTopSongsResponse>(credentials, "/rest/getTopSongs", {
artist: name,
count: 50,
})
.then((it) => it.topSongs.song || [])
.then((songs) =>
Promise.all(
songs.map((song) =>
subsonic
.getAlbum(credentials, song.albumId!)
.then((album) => asTrack(album, song, this.customPlayers))
)
)
)
), ),
radioStations: async () => subsonic responseType: "stream",
.getJSON<GetInternetRadioStationsResponse>( }
credentials, )
"/rest/getInternetRadioStations" .then((stream) => ({
) status: stream.status,
.then((it) => it.internetRadioStations.internetRadioStation || []) headers: {
.then((stations) => stations.map((it) => ({ "content-type": stream.headers["content-type"],
id: it.id, "content-length": stream.headers["content-length"],
name: it.name, "content-range": stream.headers["content-range"],
url: it.streamUrl, "accept-ranges": stream.headers["accept-ranges"],
homePage: it.homePageUrl },
}))), stream: stream.data,
radioStation: async (id: string) => genericSubsonic }));
.radioStations()
.then(it =>
it.find(station => station.id === id)!
),
};
if (credentials.type == "navidrome") { playlists = (credentials: Credentials) =>
// todo: there does not seem to be a test for this?? this.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
return Promise.resolve({ .then(({ playlists }) => (playlists.playlist || []).map( it => ({
...genericSubsonic, id: it.id,
flavour: () => "navidrome", name: it.name,
bearerToken: (credentials: Credentials) => coverArt: coverArtURN(it.coverArt),
pipe( }))
TE.tryCatch( );
() =>
axios.post( playlist = (credentials: Credentials, id: string) =>
this.url.append({ pathname: "/auth/login" }).href(), this.getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", {
_.pick(credentials, "username", "password") id,
), })
() => new AuthFailure("Failed to get bearerToken") .then(({ playlist }) => {
), let trackNumber = 1;
TE.map((it) => it.data.token as string | undefined) return {
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
entries: (playlist.entry || []).map((entry) => ({
...asTrack(
{
id: entry.albumId!,
name: entry.album!,
year: entry.year,
genre: maybeAsGenre(entry.genre),
artistName: entry.artist,
artistId: entry.artistId,
coverArt: coverArtURN(entry.coverArt),
},
entry,
this.customPlayers
), ),
}); number: trackNumber++,
} else { })),
return Promise.resolve(genericSubsonic); };
} });
};
} createPlayList = (credentials: Credentials, name: string) =>
this.getJSON<GetPlaylistResponse>(credentials, "/rest/createPlaylist", {
name,
})
.then(({ playlist }) => ({
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
}));
deletePlayList = (credentials: Credentials, id: string) =>
this.getJSON<SubsonicResponse>(credentials, "/rest/deletePlaylist", {
id,
})
.then(it => it.status == "ok");
updatePlaylist = (
credentials: Credentials,
playlistId: string,
changes : Partial<{ songIdToAdd: string | undefined, songIndexToRemove: number[] | undefined }> = {}
) =>
this.getJSON<SubsonicResponse>(credentials, "/rest/updatePlaylist", {
playlistId,
...changes
})
.then(it => it.status == "ok");
getSimilarSongs2 = (credentials: Credentials, id: string) =>
this.getJSON<GetSimilarSongsResponse>(
credentials,
"/rest/getSimilarSongs2",
//todo: remove this hard coded 50?
{ id, count: 50 }
)
.then((it) =>
(it.similarSongs2.song || []).map(it => asTrackSummary(it, this.customPlayers))
);
getTopSongs = (credentials: Credentials, artist: string) =>
this.getJSON<GetTopSongsResponse>(
credentials,
"/rest/getTopSongs",
//todo: remove this hard coded 50?
{ artist, count: 50 }
)
.then((it) =>
(it.topSongs.song || []).map(it => asTrackSummary(it, this.customPlayers))
);
getInternetRadioStations = (credentials: Credentials) =>
this.getJSON<GetInternetRadioStationsResponse>(
credentials,
"/rest/getInternetRadioStations"
)
.then((it) => it.internetRadioStations.internetRadioStation || [])
.then((stations) =>
stations.map((it) => ({
id: it.id,
name: it.name,
url: it.streamUrl,
homePage: it.homePageUrl,
}))
);
};

View File

@@ -0,0 +1,320 @@
import { taskEither as TE } from "fp-ts";
import { pipe } from "fp-ts/lib/function";
import {
Credentials,
MusicService,
ArtistSummary,
Result,
slice2,
AlbumQuery,
ArtistQuery,
MusicLibrary,
Album,
AlbumSummary,
Rating,
Artist,
AuthFailure,
AuthSuccess,
} from "./music_library";
import {
Subsonic,
CustomPlayers,
NO_CUSTOM_PLAYERS,
asToken,
parseToken,
artistImageURN,
asYear,
isValidImage
} from "./subsonic";
import _ from "underscore";
import axios from "axios";
import logger from "./logger";
import { assertSystem, BUrn } from "./burn";
export class SubsonicMusicService implements MusicService {
subsonic: Subsonic;
customPlayers: CustomPlayers;
constructor(
subsonic: Subsonic,
customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS
) {
this.subsonic = subsonic;
this.customPlayers = customPlayers;
}
generateToken = (
credentials: Credentials
): TE.TaskEither<AuthFailure, AuthSuccess> =>
pipe(
this.subsonic.ping(credentials),
TE.flatMap(({ type }) => TE.tryCatch(
() => this.libraryFor({ ...credentials, type }).then(library => ({ type, library })),
() => new AuthFailure("Failed to get library")
)),
TE.flatMap(({ library, type }) => pipe(
library.bearerToken(credentials),
TE.map(bearer => ({ bearer, type }))
)),
TE.map(({ bearer, type}) => ({
serviceToken: asToken({ ...credentials, bearer, type }),
userId: credentials.username,
nickname: credentials.username,
}))
);
refreshToken = (serviceToken: string) =>
this.generateToken(parseToken(serviceToken));
login = async (token: string) => this.libraryFor(parseToken(token));
private libraryFor = (
credentials: Credentials & { type: string }
): Promise<SubsonicMusicLibrary> => {
const genericSubsonic = new SubsonicMusicLibrary(
this.subsonic,
credentials,
this.customPlayers
);
// return Promise.resolve(genericSubsonic);
if (credentials.type == "navidrome") {
// todo: there does not seem to be a test for this??
const nd: SubsonicMusicLibrary = {
...genericSubsonic,
flavour: () => "navidrome",
bearerToken: (credentials: Credentials) =>
pipe(
TE.tryCatch(
() =>
axios.post(
this.subsonic.url.append({ pathname: "/auth/login" }).href(),
_.pick(credentials, "username", "password")
),
() => new AuthFailure("Failed to get bearerToken")
),
TE.map((it) => it.data.token as string | undefined)
),
};
return Promise.resolve(nd);
} else {
return Promise.resolve(genericSubsonic);
}
};
}
export class SubsonicMusicLibrary implements MusicLibrary {
subsonic: Subsonic;
credentials: Credentials;
customPlayers: CustomPlayers;
constructor(
subsonic: Subsonic,
credentials: Credentials,
customPlayers: CustomPlayers
) {
this.subsonic = subsonic;
this.credentials = credentials;
this.customPlayers = customPlayers;
}
flavour = () => "subsonic";
bearerToken = (_: Credentials) =>
TE.right<AuthFailure, string | undefined>(undefined);
// todo: q needs to support greater than the max page size supported by subsonic
// maybe subsonic should error?
artists = (q: ArtistQuery): Promise<Result<ArtistSummary>> =>
this.subsonic
.getArtists(this.credentials)
.then(slice2(q))
.then(([page, total]) => ({
total,
results: page,
}));
artist = async (id: string): Promise<Artist> =>
Promise.all([
this.subsonic.getArtist(this.credentials, id),
this.subsonic.getArtistInfo(this.credentials, id),
]).then(([artist, artistInfo]) => ({
id: artist.id,
name: artist.name,
image: artistImageURN({
artistId: artist.id,
artistImageURL: [
artist.artistImageUrl,
// todo: subsonic.artistInfo should just return a valid image or undefined, then the music lib just chooses first undefined
// out of artist.image and artistInfo.image
artistInfo.images.l,
artistInfo.images.m,
artistInfo.images.s,
// todo: do we still need this isValidImage?
].find(isValidImage),
}),
albums: artist.albums,
similarArtists: artistInfo.similarArtist,
}));
albums = async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
this.subsonic.getAlbumList2(this.credentials, q);
album = (id: string): Promise<Album> =>
this.subsonic.getAlbum(this.credentials, id);
genres = () =>
this.subsonic.getGenres(this.credentials);
track = (trackId: string) =>
this.subsonic.getTrack(this.credentials, trackId);
rate = (trackId: string, rating: Rating) =>
// todo: this is a bit odd
Promise.resolve(true)
.then(() => {
if (rating.stars >= 0 && rating.stars <= 5) {
return this.subsonic.getTrack(this.credentials, trackId);
} else {
throw `Invalid rating.stars value of ${rating.stars}`;
}
})
.then((track) => {
const thingsToUpdate = [];
if (track.rating.love != rating.love) {
thingsToUpdate.push(
(rating.love ? this.subsonic.star : this.subsonic.unstar)(this.credentials,{ id: trackId })
);
}
if (track.rating.stars != rating.stars) {
thingsToUpdate.push(
this.subsonic.setRating(this.credentials, trackId, rating.stars)
);
}
return Promise.all(thingsToUpdate);
})
.then(() => true)
.catch(() => false);
stream = async ({
trackId,
range,
}: {
trackId: string;
range: string | undefined;
}) =>
this.subsonic
.getTrack(this.credentials, trackId)
.then((track) =>
this.subsonic.stream(this.credentials, trackId, track.encoding.player, range)
);
coverArt = async (coverArtURN: BUrn, size?: number) =>
Promise.resolve(coverArtURN)
.then((it) => assertSystem(it, "subsonic"))
.then((it) =>
this.subsonic.getCoverArt(
this.credentials,
it.resource.split(":")[1]!,
size
)
)
.then((res) => ({
contentType: res.headers["content-type"],
data: Buffer.from(res.data, "binary"),
}))
.catch((e) => {
logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`);
return undefined;
});
// todo: unit test the difference between scrobble and nowPlaying
scrobble = async (id: string) =>
this.subsonic.scrobble(this.credentials, id, true);
nowPlaying = async (id: string) =>
this.subsonic.scrobble(this.credentials, id, false);
searchArtists = async (query: string) =>
this.subsonic
.search3(this.credentials, { query, artistCount: 20 })
.then(({ artists }) =>
artists.map((artist) => ({
id: artist.id,
name: artist.name,
image: artistImageURN({
artistId: artist.id,
artistImageURL: artist.artistImageUrl,
}),
}))
);
searchAlbums = async (query: string) =>
this.subsonic
.search3(this.credentials, { query, albumCount: 20 })
.then(({ albums }) => this.subsonic.toAlbumSummary(albums));
searchTracks = async (query: string) =>
this.subsonic
.search3(this.credentials, { query, songCount: 20 })
.then(({ songs }) =>
Promise.all(
songs.map((it) => this.subsonic.getTrack(this.credentials, it.id))
)
);
playlists = async () =>
this.subsonic.playlists(this.credentials);
playlist = async (id: string) =>
this.subsonic.playlist(this.credentials, id);
createPlaylist = async (name: string) =>
this.subsonic.createPlayList(this.credentials, name);
deletePlaylist = async (id: string) =>
this.subsonic.deletePlayList(this.credentials, id);
addToPlaylist = async (playlistId: string, trackId: string) =>
this.subsonic.updatePlaylist(this.credentials, playlistId, { songIdToAdd: trackId });
removeFromPlaylist = async (playlistId: string, indicies: number[]) =>
this.subsonic.updatePlaylist(this.credentials, playlistId, { songIndexToRemove: indicies });
similarSongs = async (id: string) =>
this.subsonic.getSimilarSongs2(this.credentials, id)
topSongs = async (artistId: string) =>
this.subsonic.getArtist(this.credentials, artistId)
.then(({ name }) =>
this.subsonic.getTopSongs(this.credentials, name)
);
radioStations = async () =>
this.subsonic.getInternetRadioStations(this.credentials);
radioStation = async (id: string) =>
this.radioStations().then((it) => it.find((station) => station.id === id)!);
years = async () => {
const q: AlbumQuery = {
_index: 0,
_count: 100000, // FIXME: better than this, probably doesnt work anyway as max _count is 500 or something
type: "alphabeticalByArtist",
};
const years = this.subsonic
.getAlbumList2(this.credentials, q)
.then(({ results }) =>
results
.map((album) => album.year || "?")
.filter((item, i, ar) => ar.indexOf(item) === i)
.sort()
.map((year) => ({
...asYear(year),
}))
.reverse()
);
return years;
};
}

View File

@@ -1,7 +1,34 @@
import { DOMParser, XMLSerializer, Node } from '@xmldom/xmldom';
export function takeWithRepeats<T>(things:T[], count: number) { export function takeWithRepeats<T>(things:T[], count: number) {
const result = []; const result = [];
for(let i = 0; i < count; i++) { for(let i = 0; i < count; i++) {
result.push(things[i % things.length]) result.push(things[i % things.length])
} }
return result; return result;
} }
function xmlRemoveWhitespaceNodes(node: Node) {
let child = node.firstChild;
while (child) {
const nextSibling = child.nextSibling;
if (child.nodeType === 3 && !child.nodeValue?.trim()) {
// Remove empty text nodes
node.removeChild(child);
} else {
// Recursively process child nodes
xmlRemoveWhitespaceNodes(child);
}
child = nextSibling;
}
}
export function xmlTidy(xml: string | Node) {
const xmlToString = new XMLSerializer().serializeToString
const xmlString = xml instanceof Node ? xmlToString(xml as any) : xml
const doc = new DOMParser().parseFromString(xmlString, 'text/xml') as unknown as Node;
xmlRemoveWhitespaceNodes(doc);
return xmlToString(doc as any);
}

View File

@@ -8,14 +8,14 @@ import {
Album, Album,
Artist, Artist,
Track, Track,
albumToAlbumSummary,
artistToArtistSummary,
PlaylistSummary, PlaylistSummary,
Playlist, Playlist,
SimilarArtist, SimilarArtist,
AlbumSummary, AlbumSummary,
RadioStation, RadioStation,
} from "../src/music_service"; ArtistSummary,
TrackSummary
} from "../src/music_library";
import { b64Encode } from "../src/b64"; import { b64Encode } from "../src/b64";
import { artistImageURN } from "../src/subsonic"; import { artistImageURN } from "../src/subsonic";
@@ -116,13 +116,26 @@ export function aSimilarArtist(
}; };
} }
export function anArtist(fields: Partial<Artist> = {}): Artist { export function anArtistSummary(fields: Partial<ArtistSummary> = {}): ArtistSummary {
const id = fields.id || uuid(); const id = fields.id || uuid();
const artist = { return {
id, id,
name: `Artist ${id}`, name: `Artist ${id}`,
albums: [anAlbum(), anAlbum(), anAlbum()],
image: { system: "subsonic", resource: `art:${id}` }, image: { system: "subsonic", resource: `art:${id}` },
}
}
export function anArtist(fields: Partial<Artist> = {}): Artist {
const id = fields.id || uuid();
const name = `Artist ${randomstring.generate()}`
const albums = fields.albums || [
anAlbumSummary({ artistId: id, artistName: name }),
anAlbumSummary({ artistId: id, artistName: name }),
anAlbumSummary({ artistId: id, artistName: name })
];
const artist = {
...anArtistSummary({ id, name }),
albums,
similarArtists: [ similarArtists: [
aSimilarArtist({ id: uuid(), name: "Similar artist1", inLibrary: true }), aSimilarArtist({ id: uuid(), name: "Similar artist1", inLibrary: true }),
aSimilarArtist({ id: uuid(), name: "Similar artist2", inLibrary: true }), aSimilarArtist({ id: uuid(), name: "Similar artist2", inLibrary: true }),
@@ -166,9 +179,9 @@ export const SAMPLE_GENRES = [
]; ];
export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)]; export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)];
export function aTrack(fields: Partial<Track> = {}): Track { export function aTrackSummary(fields: Partial<TrackSummary> = {}): TrackSummary {
const id = uuid(); const id = uuid();
const artist = anArtist(); const artist = fields.artist || anArtistSummary();
const genre = fields.genre || randomGenre(); const genre = fields.genre || randomGenre();
const rating = { love: false, stars: Math.floor(Math.random() * 5) }; const rating = { love: false, stars: Math.floor(Math.random() * 5) };
return { return {
@@ -181,28 +194,53 @@ export function aTrack(fields: Partial<Track> = {}): Track {
duration: randomInt(500), duration: randomInt(500),
number: randomInt(100), number: randomInt(100),
genre, genre,
artist: artistToArtistSummary(artist), artist,
album: albumToAlbumSummary(
anAlbum({ artistId: artist.id, artistName: artist.name, genre })
),
coverArt: { system: "subsonic", resource: `art:${uuid()}`}, coverArt: { system: "subsonic", resource: `art:${uuid()}`},
rating, rating,
...fields, ...fields,
}; };
} };
export function anAlbum(fields: Partial<Album> = {}): Album { export function aTrack(fields: Partial<Track> = {}): Track {
const summary = aTrackSummary(fields);
const album = fields.album || anAlbumSummary({ artistId: summary.artist.id, artistName: summary.artist.name, genre: summary.genre })
return {
...summary,
album,
...fields
};
};
export function anAlbumSummary(fields: Partial<AlbumSummary> = {}): AlbumSummary {
const id = uuid(); const id = uuid();
return { return {
id, id,
name: `Album ${id}`, name: `Album ${id}`,
genre: randomGenre(),
year: `19${randomInt(99)}`, year: `19${randomInt(99)}`,
genre: randomGenre(),
coverArt: { system: "subsonic", resource: `art:${uuid()}` },
artistId: `Artist ${uuid()}`, artistId: `Artist ${uuid()}`,
artistName: `Artist ${randomstring.generate()}`, artistName: `Artist ${randomstring.generate()}`,
coverArt: { system: "subsonic", resource: `art:${uuid()}` }, ...fields
};
};
export function anAlbum(fields: Partial<Album> = {}): Album {
const albumSummary = anAlbumSummary()
const album = {
...albumSummary,
tracks: [],
...fields, ...fields,
}; };
const artistSummary = anArtistSummary({ id: album.artistId, name: album.artistName })
const tracks = fields.tracks || [
aTrack({ album: albumSummary, artist: artistSummary }),
aTrack({ album: albumSummary, artist: artistSummary })
]
return {
...album,
tracks
};
}; };
export function aRadioStation(fields: Partial<RadioStation> = {}): RadioStation { export function aRadioStation(fields: Partial<RadioStation> = {}): RadioStation {
@@ -216,20 +254,6 @@ export function aRadioStation(fields: Partial<RadioStation> = {}): RadioStation
} }
} }
export function anAlbumSummary(fields: Partial<AlbumSummary> = {}): AlbumSummary {
const id = uuid();
return {
id,
name: `Album ${id}`,
year: `19${randomInt(99)}`,
genre: randomGenre(),
coverArt: { system: "subsonic", resource: `art:${uuid()}` },
artistId: `Artist ${uuid()}`,
artistName: `Artist ${randomstring.generate()}`,
...fields
}
};
export const BLONDIE_ID = uuid(); export const BLONDIE_ID = uuid();
export const BLONDIE_NAME = "Blondie"; export const BLONDIE_NAME = "Blondie";
export const BLONDIE: Artist = { export const BLONDIE: Artist = {

View File

@@ -1,3 +1,5 @@
import { left, right } from 'fp-ts/Either'
import { cryptoEncryption, jwsEncryption } from '../src/encryption'; import { cryptoEncryption, jwsEncryption } from '../src/encryption';
describe("jwsEncryption", () => { describe("jwsEncryption", () => {
@@ -7,7 +9,7 @@ describe("jwsEncryption", () => {
const value = "bobs your uncle" const value = "bobs your uncle"
const hash = e.encrypt(value) const hash = e.encrypt(value)
expect(hash).not.toContain(value); expect(hash).not.toContain(value);
expect(e.decrypt(hash)).toEqual(value); expect(e.decrypt(hash)).toEqual(right(value));
}); });
it("returns different values for different secrets", () => { it("returns different values for different secrets", () => {
@@ -29,7 +31,7 @@ describe("cryptoEncryption", () => {
const value = "bobs your uncle" const value = "bobs your uncle"
const hash = e.encrypt(value) const hash = e.encrypt(value)
expect(hash).not.toContain(value); expect(hash).not.toContain(value);
expect(e.decrypt(hash)).toEqual(value); expect(e.decrypt(hash)).toEqual(right(value));
}); });
it("returns different values for different secrets", () => { it("returns different values for different secrets", () => {
@@ -42,4 +44,10 @@ describe("cryptoEncryption", () => {
expect(h1).not.toEqual(h2); expect(h1).not.toEqual(h2);
}); });
it("should return left on invalid value", () => {
const e = cryptoEncryption("secret squirrel");
expect(e.decrypt("not-valid")).toEqual(left("Invalid value to decrypt"));
});
}) })

View File

@@ -1,6 +1,6 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import libxmljs from "libxmljs2";
import { FixedClock } from "../src/clock"; import { FixedClock } from "../src/clock";
import { xmlTidy } from "../src/utils";
import { import {
contains, contains,
@@ -20,17 +20,17 @@ import {
allOf, allOf,
features, features,
STAR_WARS, STAR_WARS,
NO_FEATURES,
} from "../src/icon"; } from "../src/icon";
describe("SvgIcon", () => { describe("SvgIcon", () => {
const xmlTidy = (xml: string) =>
libxmljs.parseXmlString(xml, { noblanks: true, net: false }).toString();
const svgIcon24 = `<?xml version="1.0" encoding="UTF-8"?> const svgIcon24 = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="path1"/> <path d="path1"/>
<path d="path2" fill="none" stroke="#000"/> <path d="path2" fill="none" stroke="#000"/>
<text font-size="25" fill="none">80's</text>
<path d="path3"/> <path d="path3"/>
<text font-size="25">80's</text>
</svg> </svg>
`; `;
@@ -61,7 +61,9 @@ describe("SvgIcon", () => {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32">
<path d="path1"/> <path d="path1"/>
<path d="path2" fill="none" stroke="#000"/> <path d="path2" fill="none" stroke="#000"/>
<text font-size="25" fill="none">80's</text>
<path d="path3"/> <path d="path3"/>
<text font-size="25">80's</text>
</svg> </svg>
`) `)
); );
@@ -110,7 +112,9 @@ describe("SvgIcon", () => {
<rect x="0" y="0" width="24" height="24" fill="red"/> <rect x="0" y="0" width="24" height="24" fill="red"/>
<path d="path1"/> <path d="path1"/>
<path d="path2" fill="none" stroke="#000"/> <path d="path2" fill="none" stroke="#000"/>
<text font-size="25" fill="none">80's</text>
<path d="path3"/> <path d="path3"/>
<text font-size="25">80's</text>
</svg> </svg>
`) `)
); );
@@ -134,7 +138,9 @@ describe("SvgIcon", () => {
<rect x="-4" y="-4" width="36" height="36" fill="pink"/> <rect x="-4" y="-4" width="36" height="36" fill="pink"/>
<path d="path1"/> <path d="path1"/>
<path d="path2" fill="none" stroke="#000"/> <path d="path2" fill="none" stroke="#000"/>
<text font-size="25" fill="none">80's</text>
<path d="path3"/> <path d="path3"/>
<text font-size="25">80's</text>
</svg> </svg>
`) `)
); );
@@ -152,7 +158,9 @@ describe("SvgIcon", () => {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="path1"/> <path d="path1"/>
<path d="path2" fill="none" stroke="#000"/> <path d="path2" fill="none" stroke="#000"/>
<text font-size="25" fill="none">80's</text>
<path d="path3"/> <path d="path3"/>
<text font-size="25">80's</text>
</svg> </svg>
`) `)
); );
@@ -172,7 +180,9 @@ describe("SvgIcon", () => {
<rect x="0" y="0" width="24" height="24" fill="red"/> <rect x="0" y="0" width="24" height="24" fill="red"/>
<path d="path1"/> <path d="path1"/>
<path d="path2" fill="none" stroke="#000"/> <path d="path2" fill="none" stroke="#000"/>
<text font-size="25" fill="none">80's</text>
<path d="path3"/> <path d="path3"/>
<text font-size="25">80's</text>
</svg> </svg>
`) `)
); );
@@ -182,7 +192,7 @@ describe("SvgIcon", () => {
describe("foreground color", () => { describe("foreground color", () => {
describe("with no viewPort increase", () => { describe("with no viewPort increase", () => {
it("should add a rectangle the same size as the original viewPort", () => { it("should change the fill values", () => {
expect( expect(
new SvgIcon(svgIcon24) new SvgIcon(svgIcon24)
.with({ features: { foregroundColor: "red" } }) .with({ features: { foregroundColor: "red" } })
@@ -192,7 +202,9 @@ describe("SvgIcon", () => {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="path1" fill="red"/> <path d="path1" fill="red"/>
<path d="path2" fill="none" stroke="red"/> <path d="path2" fill="none" stroke="red"/>
<text font-size="25" fill="none" stroke="red">80's</text>
<path d="path3" fill="red"/> <path d="path3" fill="red"/>
<text font-size="25" fill="red">80's</text>
</svg> </svg>
`) `)
); );
@@ -200,7 +212,7 @@ describe("SvgIcon", () => {
}); });
describe("with a viewPort increase", () => { describe("with a viewPort increase", () => {
it("should add a rectangle the same size as the original viewPort", () => { it("should change the fill values", () => {
expect( expect(
new SvgIcon(svgIcon24) new SvgIcon(svgIcon24)
.with({ .with({
@@ -215,7 +227,9 @@ describe("SvgIcon", () => {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32">
<path d="path1" fill="pink"/> <path d="path1" fill="pink"/>
<path d="path2" fill="none" stroke="pink"/> <path d="path2" fill="none" stroke="pink"/>
<text font-size="25" fill="none" stroke="pink">80's</text>
<path d="path3" fill="pink"/> <path d="path3" fill="pink"/>
<text font-size="25" fill="pink">80's</text>
</svg> </svg>
`) `)
); );
@@ -233,7 +247,9 @@ describe("SvgIcon", () => {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="path1"/> <path d="path1"/>
<path d="path2" fill="none" stroke="#000"/> <path d="path2" fill="none" stroke="#000"/>
<text font-size="25" fill="none">80's</text>
<path d="path3"/> <path d="path3"/>
<text font-size="25">80's</text>
</svg> </svg>
`) `)
); );
@@ -252,7 +268,9 @@ describe("SvgIcon", () => {
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="path1" fill="red"/> <path d="path1" fill="red"/>
<path d="path2" fill="none" stroke="red"/> <path d="path2" fill="none" stroke="red"/>
<text font-size="25" fill="none" stroke="red">80's</text>
<path d="path3" fill="red"/> <path d="path3" fill="red"/>
<text font-size="25" fill="red">80's</text>
</svg> </svg>
`) `)
); );
@@ -260,6 +278,48 @@ describe("SvgIcon", () => {
}); });
}); });
describe("text", () => {
describe("when text value specified", () => {
it("should change the text values", () => {
expect(
new SvgIcon(svgIcon24)
.with({ features: { text: "yipppeeee" } })
.toString()
).toEqual(
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="path1"/>
<path d="path2" fill="none" stroke="#000"/>
<text font-size="25" fill="none">yipppeeee</text>
<path d="path3"/>
<text font-size="25">yipppeeee</text>
</svg>
`)
);
});
});
describe("of undefined", () => {
it("should not do anything", () => {
expect(
new SvgIcon(svgIcon24)
.with({ features: { text: undefined } })
.toString()
).toEqual(
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="path1"/>
<path d="path2" fill="none" stroke="#000"/>
<text font-size="25" fill="none">80's</text>
<path d="path3"/>
<text font-size="25">80's</text>
</svg>
`)
);
});
});
});
describe("swapping the svg", () => { describe("swapping the svg", () => {
describe("with no other changes", () => { describe("with no other changes", () => {
it("should swap out the svg, but maintain the IconFeatures", () => { it("should swap out the svg, but maintain the IconFeatures", () => {
@@ -318,10 +378,14 @@ describe("SvgIcon", () => {
class DummyIcon implements Icon { class DummyIcon implements Icon {
svg: string; svg: string;
features: Partial<IconFeatures>; features: IconFeatures;
constructor(svg: string, features: Partial<IconFeatures>) { constructor(svg: string, features: Partial<IconFeatures>) {
this.svg = svg; this.svg = svg;
this.features = features; this.features = {
...NO_FEATURES,
...features
};
} }
public apply = (transformer: Transformer): Icon => transformer(this); public apply = (transformer: Transformer): Icon => transformer(this);
@@ -350,6 +414,7 @@ describe("transform", () => {
viewPortIncreasePercent: 100, viewPortIncreasePercent: 100,
foregroundColor: "blue", foregroundColor: "blue",
backgroundColor: "blue", backgroundColor: "blue",
text: "a",
}, },
}) })
.apply( .apply(
@@ -357,6 +422,7 @@ describe("transform", () => {
features: { features: {
foregroundColor: "override1", foregroundColor: "override1",
backgroundColor: "override2", backgroundColor: "override2",
text: "b",
}, },
}) })
) as DummyIcon; ) as DummyIcon;
@@ -366,6 +432,7 @@ describe("transform", () => {
viewPortIncreasePercent: 100, viewPortIncreasePercent: 100,
foregroundColor: "override1", foregroundColor: "override1",
backgroundColor: "override2", backgroundColor: "override2",
text: "b",
}); });
}); });
}); });
@@ -382,6 +449,7 @@ describe("transform", () => {
viewPortIncreasePercent: 100, viewPortIncreasePercent: 100,
foregroundColor: "blue", foregroundColor: "blue",
backgroundColor: "blue", backgroundColor: "blue",
text: "bob",
}, },
}) })
.apply( .apply(
@@ -395,6 +463,7 @@ describe("transform", () => {
viewPortIncreasePercent: 100, viewPortIncreasePercent: 100,
foregroundColor: "blue", foregroundColor: "blue",
backgroundColor: "blue", backgroundColor: "blue",
text: "bob"
}); });
}); });
}); });
@@ -411,6 +480,7 @@ describe("features", () => {
viewPortIncreasePercent: 100, viewPortIncreasePercent: 100,
foregroundColor: "blue", foregroundColor: "blue",
backgroundColor: "blue", backgroundColor: "blue",
text: "foobar"
}) })
) as DummyIcon; ) as DummyIcon;
@@ -418,6 +488,7 @@ describe("features", () => {
viewPortIncreasePercent: 100, viewPortIncreasePercent: 100,
foregroundColor: "blue", foregroundColor: "blue",
backgroundColor: "blue", backgroundColor: "blue",
text: "foobar"
}); });
}); });
}); });

View File

@@ -5,8 +5,7 @@ import { InMemoryMusicService } from "./in_memory_music_service";
import { import {
MusicLibrary, MusicLibrary,
artistToArtistSummary, artistToArtistSummary,
albumToAlbumSummary, } from "../src/music_library";
} from "../src/music_service";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { import {
anArtist, anArtist,
@@ -17,6 +16,7 @@ import {
METAL, METAL,
HIP_HOP, HIP_HOP,
SKA, SKA,
anAlbumSummary,
} from "./builders"; } from "./builders";
import _ from "underscore"; import _ from "underscore";
@@ -167,23 +167,6 @@ describe("InMemoryMusicService", () => {
service.hasTracks(track1, track2, track3, track4); service.hasTracks(track1, track2, track3, track4);
}); });
describe("fetching tracks for an album", () => {
it("should return only tracks on that album", async () => {
expect(await musicLibrary.tracks(artist1Album1.id)).toEqual([
{ ...track1, rating: { love: false, stars: 0 } },
{ ...track2, rating: { love: false, stars: 0 } },
]);
});
});
describe("fetching tracks for an album that doesnt exist", () => {
it("should return empty array", async () => {
expect(await musicLibrary.tracks("non existant album id")).toEqual(
[]
);
});
});
describe("fetching a single track", () => { describe("fetching a single track", () => {
describe("when it exists", () => { describe("when it exists", () => {
it("should return the track", async () => { it("should return the track", async () => {
@@ -194,16 +177,16 @@ describe("InMemoryMusicService", () => {
}); });
describe("albums", () => { describe("albums", () => {
const artist1_album1 = anAlbum({ genre: POP }); const artist1_album1 = anAlbumSummary({ genre: POP });
const artist1_album2 = anAlbum({ genre: ROCK }); const artist1_album2 = anAlbumSummary({ genre: ROCK });
const artist1_album3 = anAlbum({ genre: METAL }); const artist1_album3 = anAlbumSummary({ genre: METAL });
const artist1_album4 = anAlbum({ genre: POP }); const artist1_album4 = anAlbumSummary({ genre: POP });
const artist1_album5 = anAlbum({ genre: POP }); const artist1_album5 = anAlbumSummary({ genre: POP });
const artist2_album1 = anAlbum({ genre: METAL }); const artist2_album1 = anAlbumSummary({ genre: METAL });
const artist3_album1 = anAlbum({ genre: HIP_HOP }); const artist3_album1 = anAlbumSummary({ genre: HIP_HOP });
const artist3_album2 = anAlbum({ genre: POP }); const artist3_album2 = anAlbumSummary({ genre: POP });
const artist1 = anArtist({ const artist1 = anArtist({
name: "artist1", name: "artist1",
@@ -212,8 +195,8 @@ describe("InMemoryMusicService", () => {
artist1_album2, artist1_album2,
artist1_album3, artist1_album3,
artist1_album4, artist1_album4,
artist1_album5, artist1_album5
], ]
}); });
const artist2 = anArtist({ name: "artist2", albums: [artist2_album1] }); const artist2 = anArtist({ name: "artist2", albums: [artist2_album1] });
const artist3 = anArtist({ const artist3 = anArtist({
@@ -275,16 +258,16 @@ describe("InMemoryMusicService", () => {
}) })
).toEqual({ ).toEqual({
results: [ results: [
albumToAlbumSummary(artist1_album1), artist1_album1,
albumToAlbumSummary(artist1_album2), artist1_album2,
albumToAlbumSummary(artist1_album3), artist1_album3,
albumToAlbumSummary(artist1_album4), artist1_album4,
albumToAlbumSummary(artist1_album5), artist1_album5,
albumToAlbumSummary(artist2_album1), artist2_album1,
albumToAlbumSummary(artist3_album1), artist3_album1,
albumToAlbumSummary(artist3_album2), artist3_album2,
], ],
total: totalAlbumCount, total: totalAlbumCount,
}); });
@@ -300,7 +283,7 @@ describe("InMemoryMusicService", () => {
type: "alphabeticalByName", type: "alphabeticalByName",
}) })
).toEqual({ ).toEqual({
results: _.sortBy(allAlbums, "name").map(albumToAlbumSummary), results: _.sortBy(allAlbums, "name"),
total: totalAlbumCount, total: totalAlbumCount,
}); });
}); });
@@ -317,9 +300,9 @@ describe("InMemoryMusicService", () => {
}) })
).toEqual({ ).toEqual({
results: [ results: [
albumToAlbumSummary(artist1_album5), artist1_album5,
albumToAlbumSummary(artist2_album1), artist2_album1,
albumToAlbumSummary(artist3_album1), artist3_album1,
], ],
total: totalAlbumCount, total: totalAlbumCount,
}); });
@@ -336,8 +319,8 @@ describe("InMemoryMusicService", () => {
}) })
).toEqual({ ).toEqual({
results: [ results: [
albumToAlbumSummary(artist3_album1), artist3_album1,
albumToAlbumSummary(artist3_album2), artist3_album2,
], ],
total: totalAlbumCount, total: totalAlbumCount,
}); });
@@ -357,10 +340,10 @@ describe("InMemoryMusicService", () => {
}) })
).toEqual({ ).toEqual({
results: [ results: [
albumToAlbumSummary(artist1_album1), artist1_album1,
albumToAlbumSummary(artist1_album4), artist1_album4,
albumToAlbumSummary(artist1_album5), artist1_album5,
albumToAlbumSummary(artist3_album2), artist3_album2,
], ],
total: 4, total: 4,
}); });
@@ -379,8 +362,8 @@ describe("InMemoryMusicService", () => {
}) })
).toEqual({ ).toEqual({
results: [ results: [
albumToAlbumSummary(artist1_album4), artist1_album4,
albumToAlbumSummary(artist1_album5), artist1_album5,
], ],
total: 4, total: 4,
}); });
@@ -397,7 +380,7 @@ describe("InMemoryMusicService", () => {
_count: 100, _count: 100,
}) })
).toEqual({ ).toEqual({
results: [albumToAlbumSummary(artist3_album2)], results: [artist3_album2],
total: 4, total: 4,
}); });
}); });
@@ -424,7 +407,10 @@ describe("InMemoryMusicService", () => {
describe("when it exists", () => { describe("when it exists", () => {
it("should provide an album", async () => { it("should provide an album", async () => {
expect(await musicLibrary.album(artist1_album5.id)).toEqual( expect(await musicLibrary.album(artist1_album5.id)).toEqual(
artist1_album5 {
...artist1_album5,
tracks: []
}
); );
}); });
}); });

View File

@@ -19,11 +19,10 @@ import {
slice2, slice2,
asResult, asResult,
artistToArtistSummary, artistToArtistSummary,
albumToAlbumSummary,
Track, Track,
Genre, Genre,
Rating, Rating,
} from "../src/music_service"; } from "../src/music_library";
import { BUrn } from "../src/burn"; import { BUrn } from "../src/burn";
export class InMemoryMusicService implements MusicService { export class InMemoryMusicService implements MusicService {
@@ -97,14 +96,13 @@ export class InMemoryMusicService implements MusicService {
} }
}) })
.then((matches) => matches.map((it) => it.album)) .then((matches) => matches.map((it) => it.album))
.then((it) => it.map(albumToAlbumSummary))
.then(slice2(q)) .then(slice2(q))
.then(asResult), .then(asResult),
album: (id: string) => album: (id: string) =>
pipe( pipe(
this.artists.flatMap((it) => it.albums).find((it) => it.id === id), this.artists.flatMap((it) => it.albums).find((it) => it.id === id),
O.fromNullable, O.fromNullable,
O.map((it) => Promise.resolve(it)), O.map((it) => Promise.resolve({ ...it, tracks: [] })),
O.getOrElse(() => Promise.reject(`No album with id '${id}'`)) O.getOrElse(() => Promise.reject(`No album with id '${id}'`))
), ),
genres: () => genres: () =>
@@ -119,12 +117,6 @@ export class InMemoryMusicService implements MusicService {
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)
.map((it) => ({ ...it, rating: { love: false, stars: 0 } }))
),
rate: (_: string, _2: Rating) => Promise.resolve(false), rate: (_: string, _2: Rating) => Promise.resolve(false),
track: (trackId: string) => track: (trackId: string) =>
pipe( pipe(
@@ -163,6 +155,7 @@ export class InMemoryMusicService implements MusicService {
topSongs: async (_: string) => Promise.resolve([]), topSongs: async (_: string) => Promise.resolve([]),
radioStations: async () => Promise.resolve([]), radioStations: async () => Promise.resolve([]),
radioStation: async (_: string) => Promise.reject("Unsupported operation"), radioStation: async (_: string) => Promise.reject("Unsupported operation"),
years: async () => Promise.resolve([]),
}); });
} }

View File

@@ -1,7 +1,7 @@
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { anArtist } from "./builders"; import { anArtist } from "./builders";
import { artistToArtistSummary } from "../src/music_service"; import { artistToArtistSummary } from "../src/music_library";
describe("artistToArtistSummary", () => { describe("artistToArtistSummary", () => {
it("should map fields correctly", () => { it("should map fields correctly", () => {

View File

@@ -18,7 +18,7 @@ import {
} from "./builders"; } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryMusicService } from "./in_memory_music_service";
import { InMemoryLinkCodes } from "../src/link_codes"; import { InMemoryLinkCodes } from "../src/link_codes";
import { Credentials } from "../src/music_service"; import { Credentials } from "../src/music_library";
import makeServer from "../src/server"; import makeServer from "../src/server";
import { Service, bonobService, Sonos } from "../src/sonos"; import { Service, bonobService, Sonos } from "../src/sonos";
import supersoap from "./supersoap"; import supersoap from "./supersoap";

View File

@@ -4,7 +4,7 @@ import request from "supertest";
import Image from "image-js"; import Image from "image-js";
import { either as E, taskEither as TE } from "fp-ts"; import { either as E, taskEither as TE } from "fp-ts";
import { AuthFailure, MusicService } from "../src/music_service"; import { AuthFailure, MusicService } from "../src/music_library";
import makeServer, { import makeServer, {
BONOB_ACCESS_TOKEN_HEADER, BONOB_ACCESS_TOKEN_HEADER,
RangeBytesFromFilter, RangeBytesFromFilter,
@@ -1366,11 +1366,25 @@ describe("server", () => {
"..%2F..%2Ffoo", "..%2F..%2Ffoo",
"%2Fetc%2Fpasswd", "%2Fetc%2Fpasswd",
".%2Fbob.js", ".%2Fbob.js",
".",
"..",
"1",
"%23%24", "%23%24",
].forEach((type) => {
describe(`trying to retrieve an icon with name ${type}`, () => {
it(`should fail`, async () => {
const response = await request(server()).get(
`/icon/${type}/size/legacy`
);
expect(response.status).toEqual(400);
});
});
});
});
describe("missing icons", () => {
[
"1",
"notAValidIcon", "notAValidIcon",
"notAValidIcon:withSomeText"
].forEach((type) => { ].forEach((type) => {
describe(`trying to retrieve an icon with name ${type}`, () => { describe(`trying to retrieve an icon with name ${type}`, () => {
it(`should fail`, async () => { it(`should fail`, async () => {
@@ -1398,6 +1412,20 @@ describe("server", () => {
}); });
}); });
describe("invalid text", () => {
["..", "foobar.123", "_dog_", "{ whoop }"].forEach((text) => {
describe(`trying to retrieve an icon with text ${text}`, () => {
it(`should fail`, async () => {
const response = await request(server()).get(
`/icon/yyyy:${text}/size/60`
);
expect(response.status).toEqual(400);
});
});
});
});
describe("fetching", () => { describe("fetching", () => {
[ [
"artists", "artists",
@@ -1527,6 +1555,41 @@ describe("server", () => {
}); });
}); });
}); });
describe("specifing some text", () => {
const text = "somethingWicked"
describe(`legacy icon`, () => {
it("should return the png image", async () => {
const response = await request(server()).get(
`/icon/yyyy:${text}/size/legacy`
);
expect(response.status).toEqual(200);
expect(response.header["content-type"]).toEqual("image/png");
const image = await Image.load(response.body);
expect(image.width).toEqual(80);
expect(image.height).toEqual(80);
});
});
describe("svg icon", () => {
it(`should return an svg image with the text replaced`, async () => {
const response = await request(server()).get(
`/icon/yyyy:${text}/size/60`
);
expect(response.status).toEqual(200);
expect(response.header["content-type"]).toEqual(
"image/svg+xml; charset=utf-8"
);
const svg = Buffer.from(response.body).toString();
expect(svg).toContain(
`>${text}</text>`
);
});
});
});
}); });
}); });
}); });

View File

@@ -41,6 +41,8 @@ import {
PUNK, PUNK,
aPlaylist, aPlaylist,
aRadioStation, aRadioStation,
anArtistSummary,
anAlbumSummary,
} from "./builders"; } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryMusicService } from "./in_memory_music_service";
import supersoap from "./supersoap"; import supersoap from "./supersoap";
@@ -49,7 +51,7 @@ import {
artistToArtistSummary, artistToArtistSummary,
MusicService, MusicService,
playlistToPlaylistSummary, playlistToPlaylistSummary,
} from "../src/music_service"; } from "../src/music_library";
import { APITokens } from "../src/api_tokens"; import { APITokens } from "../src/api_tokens";
import dayjs from "dayjs"; import dayjs from "dayjs";
import url, { URLBuilder } from "../src/url_builder"; import url, { URLBuilder } from "../src/url_builder";
@@ -559,6 +561,24 @@ describe("coverArtURI", () => {
}); });
}); });
describe("iconArtURI", () => {
const bonobUrl = new URLBuilder(
"http://bonob.example.com:8080/context?search=yes"
);
describe("with no text", () => {
it("should return just the icon uri", () => {
expect(iconArtURI(bonobUrl, "mushroom").href()).toEqual("http://bonob.example.com:8080/context/icon/mushroom/size/legacy?search=yes")
});
});
describe("with text", () => {
it("should return just the icon uri", () => {
expect(iconArtURI(bonobUrl, "yyyy", "foobar10000").href()).toEqual("http://bonob.example.com:8080/context/icon/yyyy:foobar10000/size/legacy?search=yes")
});
});
});
describe("wsdl api", () => { describe("wsdl api", () => {
const musicService = { const musicService = {
generateToken: jest.fn(), generateToken: jest.fn(),
@@ -575,6 +595,8 @@ describe("wsdl api", () => {
artists: jest.fn(), artists: jest.fn(),
artist: jest.fn(), artist: jest.fn(),
genres: jest.fn(), genres: jest.fn(),
years: jest.fn(),
year: jest.fn(),
playlists: jest.fn(), playlists: jest.fn(),
playlist: jest.fn(), playlist: jest.fn(),
album: jest.fn(), album: jest.fn(),
@@ -1153,6 +1175,12 @@ describe("wsdl api", () => {
albumArtURI: iconArtURI(bonobUrl, "genres").href(), albumArtURI: iconArtURI(bonobUrl, "genres").href(),
itemType: "container", itemType: "container",
}, },
{
id: "years",
title: "Years",
albumArtURI: iconArtURI(bonobUrl, "music").href(),
itemType: "container",
},
{ {
id: "recentlyAdded", id: "recentlyAdded",
title: "Recently added", title: "Recently added",
@@ -1247,6 +1275,12 @@ describe("wsdl api", () => {
albumArtURI: iconArtURI(bonobUrl, "genres").href(), albumArtURI: iconArtURI(bonobUrl, "genres").href(),
itemType: "container", itemType: "container",
}, },
{
id: "years",
title: "Jaren",
albumArtURI: iconArtURI(bonobUrl, "music").href(),
itemType: "container",
},
{ {
id: "recentlyAdded", id: "recentlyAdded",
title: "Onlangs toegevoegd", title: "Onlangs toegevoegd",
@@ -1324,7 +1358,7 @@ describe("wsdl api", () => {
expect(result[0]).toEqual( expect(result[0]).toEqual(
getMetadataResult({ getMetadataResult({
mediaCollection: expectedGenres.map((genre) => ({ mediaCollection: expectedGenres.map((genre) => ({
itemType: "container", itemType: "albumList",
id: `genre:${genre.id}`, id: `genre:${genre.id}`,
title: genre.name, title: genre.name,
albumArtURI: iconArtURI( albumArtURI: iconArtURI(
@@ -1349,7 +1383,7 @@ describe("wsdl api", () => {
expect(result[0]).toEqual( expect(result[0]).toEqual(
getMetadataResult({ getMetadataResult({
mediaCollection: [PUNK, ROCK].map((genre) => ({ mediaCollection: [PUNK, ROCK].map((genre) => ({
itemType: "container", itemType: "albumList",
id: `genre:${genre.id}`, id: `genre:${genre.id}`,
title: genre.name, title: genre.name,
albumArtURI: iconArtURI( albumArtURI: iconArtURI(
@@ -1365,6 +1399,70 @@ describe("wsdl api", () => {
}); });
}); });
describe("asking for a year", () => {
const expectedYears = [{ year: "?" }, { year: "1969" }, { year: "1980" }, { year: "2001" }, { year: "2010" }];
beforeEach(() => {
musicLibrary.years.mockResolvedValue(expectedYears);
});
describe("asking for all years", () => {
it("should return a collection of years", async () => {
const result = await ws.getMetadataAsync({
id: `years`,
index: 0,
count: 100,
});
const albumListForYear = (year: string, icon: URLBuilder) => ({
itemType: "albumList",
id: `year:${year}`,
title: year,
albumArtURI: icon.href(),
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [
albumListForYear("?", iconArtURI(bonobUrl, "music")),
albumListForYear("1969", iconArtURI(bonobUrl, "yyyy", "1969")),
albumListForYear("1980", iconArtURI(bonobUrl, "yyyy", "1980")),
albumListForYear("2001", iconArtURI(bonobUrl, "yyyy", "2001")),
albumListForYear("2010", iconArtURI(bonobUrl, "yyyy", "2010")),
],
index: 0,
total: expectedYears.length,
})
);
});
});
describe("asking for a page of years", () => {
it("should return just that page", async () => {
const result = await ws.getMetadataAsync({
id: `years`,
index: 2,
count: 2,
});
expect(result[0]).toEqual(
getMetadataResult({
mediaCollection: [{ year: "1980" }, { year: "2001" }].map((year) => ({
itemType: "albumList",
id: `year:${year.year}`,
title: year.year,
albumArtURI: iconArtURI(
bonobUrl,
"yyyy",
year.year
).href(),
})),
index: 2,
total: expectedYears.length,
})
);
});
});
});
describe("asking for playlists", () => { describe("asking for playlists", () => {
const playlist1 = aPlaylist({ id: "1", name: "pl1", entries: []}); const playlist1 = aPlaylist({ id: "1", name: "pl1", entries: []});
const playlist2 = aPlaylist({ id: "2", name: "pl2", entries: []}); const playlist2 = aPlaylist({ id: "2", name: "pl2", entries: []});
@@ -2260,10 +2358,8 @@ describe("wsdl api", () => {
}); });
describe("asking for an album", () => { describe("asking for an album", () => {
const album = anAlbum(); const album = anAlbumSummary();
const artist = anArtist({ const artist = anArtistSummary();
albums: [album],
});
const track1 = aTrack({ artist, album, number: 1 }); const track1 = aTrack({ artist, album, number: 1 });
const track2 = aTrack({ artist, album, number: 2 }); const track2 = aTrack({ artist, album, number: 2 });
@@ -2274,7 +2370,12 @@ describe("wsdl api", () => {
const tracks = [track1, track2, track3, track4, track5]; const tracks = [track1, track2, track3, track4, track5];
beforeEach(() => { beforeEach(() => {
musicLibrary.tracks.mockResolvedValue(tracks); musicLibrary.album.mockResolvedValue(anAlbum({
...album,
artistName: artist.name,
artistId: artist.id,
tracks
}));
}); });
describe("asking for all for an album", () => { describe("asking for all for an album", () => {
@@ -2298,7 +2399,7 @@ describe("wsdl api", () => {
total: tracks.length, total: tracks.length,
}) })
); );
expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id); expect(musicLibrary.album).toHaveBeenCalledWith(album.id);
}); });
}); });
@@ -2325,7 +2426,7 @@ describe("wsdl api", () => {
total: tracks.length, total: tracks.length,
}) })
); );
expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id); expect(musicLibrary.album).toHaveBeenCalledWith(album.id);
}); });
}); });
}); });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3
web/icons/yy.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<text x="50" y="75" font-size="65" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold">80s</text>
</svg>

After

Width:  |  Height:  |  Size: 189 B

3
web/icons/yyyy.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<text x="50" y="65" font-size="35" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold">1980</text>
</svg>

After

Width:  |  Height:  |  Size: 190 B