mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Compare commits
21 Commits
feature/ye
...
feature/no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6897397c28 | ||
|
|
3a14b62de4 | ||
|
|
9e5df22701 | ||
|
|
e29d5c5d24 | ||
|
|
b97590dd36 | ||
|
|
b0dc11abcb | ||
|
|
5009732da2 | ||
|
|
ddde55d02b | ||
|
|
0602e1f077 | ||
|
|
7eeedff040 | ||
|
|
0451c3a931 | ||
|
|
cc0dc3704d | ||
|
|
dabb7d0f12 | ||
|
|
a38ca831df | ||
|
|
2961b651d9 | ||
|
|
d8d532e35f | ||
|
|
a581100d29 | ||
|
|
6bc4c79f02 | ||
|
|
dd52c5706b | ||
|
|
996582ce93 | ||
|
|
0488f398c1 |
@@ -1,4 +1,4 @@
|
||||
FROM node:20-bullseye
|
||||
FROM node:23-bullseye
|
||||
|
||||
LABEL maintainer=simojenki
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-bullseye-slim as build
|
||||
FROM node:23-bullseye-slim AS build
|
||||
|
||||
WORKDIR /bonob
|
||||
|
||||
@@ -36,7 +36,7 @@ RUN apt-get update && \
|
||||
NODE_ENV=production npm install --omit=dev
|
||||
|
||||
|
||||
FROM node:20-bullseye-slim
|
||||
FROM node:23-bullseye-slim
|
||||
|
||||
LABEL maintainer="simojenki" \
|
||||
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/.gitinfo ./
|
||||
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 && \
|
||||
apt-get -y upgrade && \
|
||||
|
||||
@@ -9,14 +9,14 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
|
||||
## Features
|
||||
|
||||
- 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
|
||||
- View Related Artists via Artist -> '...' -> Menu -> Related Arists
|
||||
- Now playing & Track Scrobbling
|
||||
- Search by Album, Artist, Track
|
||||
- Playlist editing through sonos app.
|
||||
- Marking of songs as favourites and with ratings through the sonos app.
|
||||
- Localization (only en-US, 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
|
||||
- Discovery of sonos devices using seed IP address
|
||||
- Auto registration with sonos on start
|
||||
|
||||
1690
package-lock.json
generated
1690
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
64
package.json
64
package.json
@@ -6,56 +6,56 @@
|
||||
"author": "simojenki <simojenki@users.noreply.github.com>",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@svrooij/sonos": "^2.6.0-beta.7",
|
||||
"@svrooij/sonos": "^2.6.0-beta.11",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/jws": "^3.2.9",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/jws": "^3.2.10",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/randomstring": "^1.1.11",
|
||||
"@types/underscore": "^1.11.15",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@types/xmldom": "0.1.34",
|
||||
"axios": "^1.6.5",
|
||||
"dayjs": "^1.11.10",
|
||||
"@types/randomstring": "^1.3.0",
|
||||
"@types/underscore": "^1.13.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/xmldom": "^0.1.34",
|
||||
"@xmldom/xmldom": "^0.9.7",
|
||||
"axios": "^1.7.8",
|
||||
"dayjs": "^1.11.13",
|
||||
"eta": "^2.2.0",
|
||||
"express": "^4.18.2",
|
||||
"fp-ts": "^2.16.2",
|
||||
"express": "^4.18.3",
|
||||
"fp-ts": "^2.16.9",
|
||||
"fs-extra": "^11.2.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jws": "^4.0.0",
|
||||
"libxmljs2": "^0.33.0",
|
||||
"morgan": "^1.10.0",
|
||||
"node-html-parser": "^6.1.12",
|
||||
"node-html-parser": "^6.1.13",
|
||||
"randomstring": "^1.3.0",
|
||||
"sharp": "^0.33.2",
|
||||
"soap": "^1.0.0",
|
||||
"sharp": "^0.33.5",
|
||||
"soap": "^1.1.6",
|
||||
"ts-md5": "^1.3.1",
|
||||
"typescript": "^5.3.3",
|
||||
"underscore": "^1.13.6",
|
||||
"typescript": "^5.7.2",
|
||||
"underscore": "^1.13.7",
|
||||
"urn-lib": "^2.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0",
|
||||
"xmldom-ts": "^0.3.1"
|
||||
"uuid": "^11.0.3",
|
||||
"winston": "^3.17.0",
|
||||
"xmldom-ts": "^0.3.1",
|
||||
"xpath": "^0.0.34"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.11",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/mocha": "^10.0.6",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/tmp": "^0.2.6",
|
||||
"chai": "^5.0.0",
|
||||
"get-port": "^7.0.0",
|
||||
"image-js": "^0.35.5",
|
||||
"chai": "^5.1.2",
|
||||
"get-port": "^7.1.0",
|
||||
"image-js": "^0.35.6",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.3",
|
||||
"supertest": "^6.3.4",
|
||||
"tmp": "^0.2.1",
|
||||
"ts-jest": "^29.1.2",
|
||||
"nodemon": "^3.1.7",
|
||||
"supertest": "^7.0.0",
|
||||
"tmp": "^0.2.3",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"xmldom-ts": "^0.3.1",
|
||||
"xpath-ts": "^1.3.13"
|
||||
},
|
||||
"overrides": {
|
||||
@@ -66,7 +66,7 @@
|
||||
"clean": "rm -Rf build node_modules",
|
||||
"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",
|
||||
"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",
|
||||
"test": "jest",
|
||||
"testw": "jest --watch",
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<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:sequence>
|
||||
</xs:complexType>
|
||||
@@ -111,11 +111,12 @@
|
||||
</xs:simpleType>
|
||||
</xs:element>
|
||||
|
||||
<xs:simpleType name="userAccountType">
|
||||
<xs:simpleType name="userAccountTier">
|
||||
<xs:restriction base="xs:string">
|
||||
<xs:enumeration value="premium"/>
|
||||
<xs:enumeration value="trial"/>
|
||||
<xs:enumeration value="paidPremium"/>
|
||||
<xs:enumeration value="paidLimited"/>
|
||||
<xs:enumeration value="free"/>
|
||||
<xs:enumeration value="none"/>
|
||||
</xs:restriction>
|
||||
</xs:simpleType>
|
||||
|
||||
@@ -239,6 +240,12 @@
|
||||
</xs:simpleContent>
|
||||
</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:restriction base="xs:string">
|
||||
<xs:enumeration value="IMPLICIT"/>
|
||||
@@ -355,13 +362,11 @@
|
||||
|
||||
<xs:complexType name="userInfo">
|
||||
<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="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 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:complexType>
|
||||
|
||||
@@ -888,7 +893,10 @@
|
||||
<xs:element name="getMediaURIResult" type="xs:anyURI"/>
|
||||
<xs:element name="deviceSessionToken" type="tns:deviceSessionToken" minOccurs="0" maxOccurs="1"/>
|
||||
<xs:element name="deviceSessionKey" 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="uriTimeout" type="xs:int" minOccurs="0" maxOccurs="1"/>
|
||||
<xs:element name="positionInformation" type="tns:positionInformation" minOccurs="0" maxOccurs="1"/>
|
||||
@@ -2059,7 +2067,7 @@
|
||||
|
||||
<wsdl:service name="Sonos">
|
||||
<wsdl:port name="SonosSoap" binding="tns:SonosSoap">
|
||||
<soap:address location="/about"/>
|
||||
<soap:address location="http://moapi.sonos.com/Test/TestService.php"/>
|
||||
</wsdl:port>
|
||||
</wsdl:service>
|
||||
|
||||
12
src/app.ts
12
src/app.ts
@@ -6,15 +6,16 @@ import logger from "./logger";
|
||||
import {
|
||||
axiosImageFetcher,
|
||||
cachingImageFetcher,
|
||||
Subsonic,
|
||||
TranscodingCustomPlayers,
|
||||
NO_CUSTOM_PLAYERS
|
||||
NO_CUSTOM_PLAYERS,
|
||||
Subsonic
|
||||
} from "./subsonic";
|
||||
import { SubsonicMusicService} from "./subsonic_music_library";
|
||||
import { InMemoryAPITokens, sha256 } from "./api_tokens";
|
||||
import { InMemoryLinkCodes } from "./link_codes";
|
||||
import readConfig from "./config";
|
||||
import sonos, { bonobService } from "./sonos";
|
||||
import { MusicService } from "./music_service";
|
||||
import { MusicService } from "./music_library";
|
||||
import { SystemClock } from "./clock";
|
||||
import { JWTSmapiLoginTokens } from "./smapi_auth";
|
||||
|
||||
@@ -40,10 +41,13 @@ const artistImageFetcher = config.subsonic.artistImageCache
|
||||
? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher)
|
||||
: axiosImageFetcher;
|
||||
|
||||
const subsonic = new Subsonic(
|
||||
const subsonic = new SubsonicMusicService(
|
||||
new Subsonic(
|
||||
config.subsonic.url,
|
||||
customPlayers,
|
||||
artistImageFetcher
|
||||
),
|
||||
customPlayers
|
||||
);
|
||||
|
||||
const featureFlagAwareMusicService: MusicService = {
|
||||
|
||||
10
src/burn.ts
10
src/burn.ts
@@ -1,6 +1,8 @@
|
||||
import _ from "underscore";
|
||||
import { createUrnUtil } from "urn-lib";
|
||||
import randomstring from "randomstring";
|
||||
import { pipe } from "fp-ts/lib/function";
|
||||
import { either as E } from "fp-ts";
|
||||
|
||||
import jwsEncryption from "./encryption";
|
||||
|
||||
@@ -78,7 +80,13 @@ export const parse = (burn: string): BUrn => {
|
||||
resource: result.resource as string,
|
||||
};
|
||||
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 {
|
||||
return x;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
randomBytes,
|
||||
createHash,
|
||||
} 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";
|
||||
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const IV = randomBytes(16);
|
||||
|
||||
|
||||
export type Hash = {
|
||||
iv: string;
|
||||
encryptedData: string;
|
||||
@@ -18,7 +19,7 @@ export type Hash = {
|
||||
|
||||
export type Encryption = {
|
||||
encrypt: (value: string) => string;
|
||||
decrypt: (value: string) => string;
|
||||
decrypt: (value: string) => Either<string, string>;
|
||||
};
|
||||
|
||||
export const jwsEncryption = (secret: string): Encryption => {
|
||||
@@ -28,7 +29,15 @@ export const jwsEncryption = (secret: string): Encryption => {
|
||||
payload: value,
|
||||
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")
|
||||
.update(String(secret))
|
||||
.digest("base64")
|
||||
.substr(0, 32);
|
||||
.substring(0, 32);
|
||||
|
||||
return {
|
||||
encrypt: (value: string) => {
|
||||
const cipher = createCipheriv(ALGORITHM, key, IV);
|
||||
@@ -45,20 +55,23 @@ export const cryptoEncryption = (secret: string): Encryption => {
|
||||
cipher.final(),
|
||||
]).toString("hex")}`;
|
||||
},
|
||||
decrypt: (value: string) => {
|
||||
const parts = value.split(".");
|
||||
if(parts.length != 2) throw `Invalid value to decrypt`;
|
||||
|
||||
const decipher = createDecipheriv(
|
||||
decrypt: (value: string) => pipe(
|
||||
right(value),
|
||||
E.map(it => it.split(".")),
|
||||
E.flatMap(it => it.length == 2 ? right({ iv: it[0]!, data: it[1]! }) : left("Invalid value to decrypt")),
|
||||
E.map(it => ({
|
||||
hash: it,
|
||||
decipher: createDecipheriv(
|
||||
ALGORITHM,
|
||||
key,
|
||||
Buffer.from(parts[0]!, "hex")
|
||||
);
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(parts[1]!, "hex")),
|
||||
decipher.final(),
|
||||
]).toString();
|
||||
},
|
||||
Buffer.from(it.iv, "hex")
|
||||
)
|
||||
})),
|
||||
E.map(it => Buffer.concat([
|
||||
it.decipher.update(Buffer.from(it.hash.data, "hex")),
|
||||
it.decipher.final(),
|
||||
]).toString())
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export type KEY =
|
||||
| "loginFailed"
|
||||
| "noSonosDevices"
|
||||
| "favourites"
|
||||
| "years"
|
||||
| "LOVE"
|
||||
| "LOVE_SUCCESS"
|
||||
| "STAR"
|
||||
@@ -83,6 +84,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "Login failed!",
|
||||
noSonosDevices: "No sonos devices",
|
||||
favourites: "Favourites",
|
||||
years: "Years",
|
||||
STAR: "Star",
|
||||
UNSTAR: "Un-star",
|
||||
STAR_SUCCESS: "Track starred",
|
||||
@@ -125,6 +127,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "Log på fejlede!",
|
||||
noSonosDevices: "Ingen Sonos enheder",
|
||||
favourites: "Favoritter",
|
||||
years: "Flere år",
|
||||
STAR: "Tilføj stjerne",
|
||||
UNSTAR: "Fjern stjerne",
|
||||
STAR_SUCCESS: "Stjerne tilføjet",
|
||||
@@ -167,6 +170,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "La connexion a échoué !",
|
||||
noSonosDevices: "Aucun appareil Sonos",
|
||||
favourites: "Favoris",
|
||||
years: "Années",
|
||||
STAR: "Suivre",
|
||||
UNSTAR: "Ne plus suivre",
|
||||
STAR_SUCCESS: "Piste suivie",
|
||||
@@ -209,6 +213,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "Inloggen mislukt!",
|
||||
noSonosDevices: "Geen Sonos-apparaten",
|
||||
favourites: "Favorieten",
|
||||
years: "Jaren",
|
||||
STAR: "Ster ",
|
||||
UNSTAR: "Een ster",
|
||||
STAR_SUCCESS: "Nummer met ster",
|
||||
|
||||
94
src/icon.ts
94
src/icon.ts
@@ -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 fs from "fs";
|
||||
|
||||
@@ -13,11 +14,10 @@ import {
|
||||
isMay4,
|
||||
SystemClock,
|
||||
} from "./clock";
|
||||
import { xmlTidy } from "./utils";
|
||||
import path from "path";
|
||||
|
||||
const SVG_NS = {
|
||||
svg: "http://www.w3.org/2000/svg",
|
||||
};
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
class ViewBox {
|
||||
minX: number;
|
||||
@@ -48,8 +48,16 @@ export type IconFeatures = {
|
||||
viewPortIncreasePercent: number | undefined;
|
||||
backgroundColor: string | undefined;
|
||||
foregroundColor: string | undefined;
|
||||
text: string | undefined;
|
||||
};
|
||||
|
||||
export const NO_FEATURES: IconFeatures = {
|
||||
viewPortIncreasePercent: undefined,
|
||||
backgroundColor: undefined,
|
||||
foregroundColor: undefined,
|
||||
text: undefined
|
||||
}
|
||||
|
||||
export type IconSpec = {
|
||||
svg: string | undefined;
|
||||
features: Partial<IconFeatures> | undefined;
|
||||
@@ -93,17 +101,11 @@ export class SvgIcon implements Icon {
|
||||
|
||||
constructor(
|
||||
svg: string,
|
||||
features: Partial<IconFeatures> = {
|
||||
viewPortIncreasePercent: undefined,
|
||||
backgroundColor: undefined,
|
||||
foregroundColor: undefined,
|
||||
}
|
||||
features: Partial<IconFeatures> = {}
|
||||
) {
|
||||
this.svg = svg;
|
||||
this.features = {
|
||||
viewPortIncreasePercent: undefined,
|
||||
backgroundColor: undefined,
|
||||
foregroundColor: undefined,
|
||||
...NO_FEATURES,
|
||||
...features,
|
||||
};
|
||||
}
|
||||
@@ -117,38 +119,44 @@ export class SvgIcon implements Icon {
|
||||
});
|
||||
|
||||
public toString = () => {
|
||||
const xml = libxmljs.parseXmlString(this.svg, {
|
||||
noblanks: true,
|
||||
net: false,
|
||||
});
|
||||
const viewBoxAttr = xml.get("//svg:svg/@viewBox", SVG_NS) as Attribute;
|
||||
let viewBox = new ViewBox(viewBoxAttr.value());
|
||||
const doc = new DOMParser().parseFromString(this.svg, 'text/xml') as unknown as Document;
|
||||
const select = xpath.useNamespaces({ svg: SVG_NS });
|
||||
|
||||
const elements = (path: string) => (select(path, doc) as Element[])
|
||||
const element = (path: string) => elements(path)[0]!
|
||||
|
||||
let viewBox = new ViewBox(select("string(//svg:svg/@viewBox)", doc) as string);
|
||||
if (
|
||||
this.features.viewPortIncreasePercent &&
|
||||
this.features.viewPortIncreasePercent > 0
|
||||
) {
|
||||
viewBox = viewBox.increasePercent(this.features.viewPortIncreasePercent);
|
||||
viewBoxAttr.value(viewBox.toString());
|
||||
element("//svg:svg").setAttribute("viewBox", viewBox.toString());
|
||||
}
|
||||
if (this.features.backgroundColor) {
|
||||
(xml.get("//svg:svg/*[1]", SVG_NS) as Element).addPrevSibling(
|
||||
new Element(xml, "rect").attr({
|
||||
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! });
|
||||
if(this.features.text) {
|
||||
elements("//svg:text").forEach((text) => {
|
||||
text.textContent = this.features.text!
|
||||
});
|
||||
}
|
||||
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"
|
||||
| "heart"
|
||||
| "star"
|
||||
| "solidStar";
|
||||
| "solidStar"
|
||||
| "yy"
|
||||
| "yyyy";
|
||||
|
||||
const iconFrom = (name: string) =>
|
||||
const svgFrom = (name: string) =>
|
||||
new SvgIcon(
|
||||
fs
|
||||
.readFileSync(path.resolve(__dirname, "..", "web", "icons", name))
|
||||
.toString()
|
||||
);
|
||||
|
||||
const iconFrom = (name: string) => svgFrom(name).with({ features: { viewPortIncreasePercent: 80 } });
|
||||
|
||||
export const ICONS: Record<ICON, SvgIcon> = {
|
||||
artists: iconFrom("navidrome-artists.svg"),
|
||||
albums: iconFrom("navidrome-all.svg"),
|
||||
radio: iconFrom("navidrome-radio.svg"),
|
||||
blank: iconFrom("blank.svg"),
|
||||
blank: svgFrom("blank.svg"),
|
||||
playlists: iconFrom("navidrome-playlists.svg"),
|
||||
genres: iconFrom("Theatre-Mask-111172.svg"),
|
||||
random: iconFrom("navidrome-random.svg"),
|
||||
@@ -307,7 +319,9 @@ export const ICONS: Record<ICON, SvgIcon> = {
|
||||
yoda: iconFrom("Yoda-68107.svg"),
|
||||
heart: iconFrom("Heart-85038.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];
|
||||
|
||||
@@ -23,7 +23,8 @@ export type ArtistSummary = {
|
||||
|
||||
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[];
|
||||
similarArtists: SimilarArtist[]
|
||||
};
|
||||
@@ -34,18 +35,21 @@ export type AlbumSummary = {
|
||||
year: string | undefined;
|
||||
genre: Genre | undefined;
|
||||
coverArt: BUrn | undefined;
|
||||
|
||||
artistName: 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 = {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type Year = {
|
||||
year: string;
|
||||
}
|
||||
|
||||
export type Rating = {
|
||||
love: boolean;
|
||||
stars: number;
|
||||
@@ -56,7 +60,7 @@ export type Encoding = {
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export type Track = {
|
||||
export type TrackSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
encoding: Encoding,
|
||||
@@ -64,9 +68,12 @@ export type Track = {
|
||||
number: number | undefined;
|
||||
genre: Genre | undefined;
|
||||
coverArt: BUrn | undefined;
|
||||
album: AlbumSummary;
|
||||
artist: ArtistSummary;
|
||||
rating: Rating;
|
||||
}
|
||||
|
||||
export type Track = TrackSummary & {
|
||||
album: AlbumSummary;
|
||||
};
|
||||
|
||||
export type RadioStation = {
|
||||
@@ -100,11 +107,13 @@ export const asResult = <T>([results, total]: [T[], number]) => ({
|
||||
|
||||
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 & {
|
||||
type: AlbumQueryType;
|
||||
genre?: string;
|
||||
fromYear?: string;
|
||||
toYear?: string;
|
||||
};
|
||||
|
||||
export const artistToArtistSummary = (it: Artist): ArtistSummary => ({
|
||||
@@ -123,6 +132,18 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
|
||||
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 => ({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
@@ -170,9 +191,9 @@ export interface MusicLibrary {
|
||||
artist(id: string): Promise<Artist>;
|
||||
albums(q: AlbumQuery): Promise<Result<AlbumSummary>>;
|
||||
album(id: string): Promise<Album>;
|
||||
tracks(albumId: string): Promise<Track[]>;
|
||||
track(trackId: string): Promise<Track>;
|
||||
genres(): Promise<Genre[]>;
|
||||
years(): Promise<Year[]>;
|
||||
stream({
|
||||
trackId,
|
||||
range,
|
||||
@@ -193,8 +214,8 @@ export interface MusicLibrary {
|
||||
deletePlaylist(id: string): Promise<boolean>
|
||||
addToPlaylist(playlistId: string, trackId: string): Promise<boolean>
|
||||
removeFromPlaylist(playlistId: string, indicies: number[]): Promise<boolean>
|
||||
similarSongs(id: string): Promise<Track[]>;
|
||||
topSongs(artistId: string): Promise<Track[]>;
|
||||
similarSongs(id: string): Promise<TrackSummary[]>;
|
||||
topSongs(artistId: string): Promise<TrackSummary[]>;
|
||||
radioStation(id: string): Promise<RadioStation>
|
||||
radioStations(): Promise<RadioStation[]>
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
ratingAsInt,
|
||||
} from "./smapi";
|
||||
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 { APITokens, InMemoryAPITokens } from "./api_tokens";
|
||||
import logger from "./logger";
|
||||
@@ -498,16 +498,18 @@ function server(
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/icon/:type/size/:size", (req, res) => {
|
||||
const type = req.params["type"]!;
|
||||
app.get("/icon/:type_text/size/:size", (req, res) => {
|
||||
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"]!;
|
||||
|
||||
if (!Object.keys(ICONS).includes(type)) {
|
||||
return res.status(404).send();
|
||||
} else if (
|
||||
size != "legacy" &&
|
||||
!SONOS_RECOMMENDED_IMAGE_SIZES.includes(size)
|
||||
) {
|
||||
} else if (size != "legacy" && !SONOS_RECOMMENDED_IMAGE_SIZES.includes(size)) {
|
||||
return res.status(400).send();
|
||||
} else {
|
||||
let icon = (ICONS as any)[type]! as Icon;
|
||||
@@ -528,8 +530,8 @@ function server(
|
||||
icon
|
||||
.apply(
|
||||
features({
|
||||
viewPortIncreasePercent: 80,
|
||||
...serverOpts.iconColors,
|
||||
text: text
|
||||
})
|
||||
)
|
||||
.apply(festivals(clock))
|
||||
|
||||
51
src/smapi.ts
51
src/smapi.ts
@@ -10,18 +10,18 @@ import logger from "./logger";
|
||||
|
||||
import { LinkCodes } from "./link_codes";
|
||||
import {
|
||||
Album,
|
||||
AlbumQuery,
|
||||
AlbumSummary,
|
||||
ArtistSummary,
|
||||
Genre,
|
||||
Year,
|
||||
MusicService,
|
||||
Playlist,
|
||||
RadioStation,
|
||||
Rating,
|
||||
slice2,
|
||||
Track,
|
||||
} from "./music_service";
|
||||
} from "./music_library";
|
||||
import { APITokens } from "./api_tokens";
|
||||
import { Clock } from "./clock";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
@@ -61,7 +61,7 @@ export const SONOS_RECOMMENDED_IMAGE_SIZES = [
|
||||
|
||||
const WSDL_FILE = path.resolve(
|
||||
__dirname,
|
||||
"Sonoswsdl-1.19.4-20190411.142401-3.wsdl"
|
||||
"Sonoswsdl-1.19.6-20231024.wsdl"
|
||||
);
|
||||
|
||||
export type Credentials = {
|
||||
@@ -244,12 +244,20 @@ export type Container = {
|
||||
};
|
||||
|
||||
const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
|
||||
itemType: "container",
|
||||
itemType: "albumList",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
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) => ({
|
||||
itemType: "playlist",
|
||||
id: `playlist:${playlist.id}`,
|
||||
@@ -278,9 +286,9 @@ export const coverArtURI = (
|
||||
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({
|
||||
pathname: `/icon/${icon}/size/legacy`,
|
||||
pathname: `/icon/${text == undefined ? icon : `${icon}:${text}`}/size/legacy`,
|
||||
});
|
||||
|
||||
export const sonosifyMimeType = (mimeType: string) =>
|
||||
@@ -603,7 +611,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
switch (type) {
|
||||
case "artist":
|
||||
return musicLibrary.artist(typeId).then((artist) => {
|
||||
const [page, total] = slice2<Album>(paging)(
|
||||
const [page, total] = slice2<AlbumSummary>(paging)(
|
||||
artist.albums
|
||||
);
|
||||
return {
|
||||
@@ -740,6 +748,12 @@ function bindSmapiSoapServiceToExpress(
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "years",
|
||||
title: lang("years"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "recentlyAdded",
|
||||
title: lang("recentlyAdded"),
|
||||
@@ -817,6 +831,13 @@ function bindSmapiSoapServiceToExpress(
|
||||
genre: typeId,
|
||||
...paging,
|
||||
});
|
||||
case "year":
|
||||
return albums({
|
||||
type: "byYear",
|
||||
fromYear: typeId,
|
||||
toYear: typeId,
|
||||
...paging,
|
||||
});
|
||||
case "randomAlbums":
|
||||
return albums({
|
||||
type: "random",
|
||||
@@ -860,6 +881,19 @@ function bindSmapiSoapServiceToExpress(
|
||||
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":
|
||||
return musicLibrary
|
||||
.genres()
|
||||
@@ -947,7 +981,8 @@ function bindSmapiSoapServiceToExpress(
|
||||
});
|
||||
case "album":
|
||||
return musicLibrary
|
||||
.tracks(typeId!)
|
||||
.album(typeId!)
|
||||
.then(it => it.tracks)
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) => {
|
||||
return getMetadataResult({
|
||||
|
||||
590
src/subsonic.ts
590
src/subsonic.ts
@@ -5,24 +5,18 @@ import { pipe } from "fp-ts/lib/function";
|
||||
import { Md5 } from "ts-md5";
|
||||
import {
|
||||
Credentials,
|
||||
MusicService,
|
||||
Album,
|
||||
Result,
|
||||
slice2,
|
||||
AlbumQuery,
|
||||
ArtistQuery,
|
||||
MusicLibrary,
|
||||
AlbumSummary,
|
||||
Genre,
|
||||
Track,
|
||||
CoverArt,
|
||||
Rating,
|
||||
AlbumQueryType,
|
||||
Artist,
|
||||
AuthFailure,
|
||||
PlaylistSummary,
|
||||
Encoding,
|
||||
} from "./music_service";
|
||||
albumToAlbumSummary,
|
||||
TrackSummary,
|
||||
AuthFailure
|
||||
} from "./music_library";
|
||||
import sharp from "sharp";
|
||||
import _ from "underscore";
|
||||
import fse from "fs-extra";
|
||||
@@ -31,9 +25,8 @@ import path from "path";
|
||||
import axios, { AxiosRequestConfig } from "axios";
|
||||
import randomstring from "randomstring";
|
||||
import { b64Encode, b64Decode } from "./b64";
|
||||
import logger from "./logger";
|
||||
import { assertSystem, BUrn } from "./burn";
|
||||
import { artist } from "./smapi";
|
||||
import { BUrn } from "./burn";
|
||||
import { album, artist } from "./smapi";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
|
||||
export const BROWSER_HEADERS = {
|
||||
@@ -108,7 +101,7 @@ type genre = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
type GetGenresResponse = SubsonicResponse & {
|
||||
export type GetGenresResponse = SubsonicResponse & {
|
||||
genres: {
|
||||
genre: genre[];
|
||||
};
|
||||
@@ -168,57 +161,70 @@ export type song = {
|
||||
transcodedContentType: string | undefined;
|
||||
type: string | undefined;
|
||||
userRating: number | undefined;
|
||||
// todo: this field shouldnt be on song?
|
||||
starred: string | undefined;
|
||||
};
|
||||
|
||||
type GetAlbumResponse = {
|
||||
export type GetAlbumResponse = {
|
||||
album: album & {
|
||||
song: song[];
|
||||
};
|
||||
};
|
||||
|
||||
type playlist = {
|
||||
id: string;
|
||||
name: string;
|
||||
coverArt: string | undefined;
|
||||
};
|
||||
|
||||
type GetPlaylistResponse = {
|
||||
export type GetPlaylistResponse = {
|
||||
// todo: isnt the type here a composite? playlistSummary && { entry: song[]; }
|
||||
playlist: {
|
||||
id: string;
|
||||
name: string;
|
||||
coverArt: string | undefined;
|
||||
entry: song[];
|
||||
|
||||
// todo: this is an ND specific field?
|
||||
coverArt: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
type GetPlaylistsResponse = {
|
||||
playlists: { playlist: playlist[] };
|
||||
export type GetPlaylistsResponse = {
|
||||
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[] };
|
||||
};
|
||||
|
||||
type GetTopSongsResponse = {
|
||||
export type GetTopSongsResponse = {
|
||||
topSongs: { song: song[] };
|
||||
};
|
||||
|
||||
type GetInternetRadioStationsResponse = {
|
||||
internetRadioStations: { internetRadioStation: {
|
||||
id: string,
|
||||
name: string,
|
||||
streamUrl: string,
|
||||
homePageUrl?: string }[]
|
||||
}
|
||||
}
|
||||
export type GetInternetRadioStationsResponse = {
|
||||
internetRadioStations: {
|
||||
internetRadioStation: {
|
||||
id: string;
|
||||
name: string;
|
||||
streamUrl: string;
|
||||
homePageUrl?: string;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
|
||||
type GetSongResponse = {
|
||||
export type GetSongResponse = {
|
||||
song: song;
|
||||
};
|
||||
|
||||
type GetStarredResponse = {
|
||||
export type GetStarredResponse = {
|
||||
starred2: {
|
||||
song: song[];
|
||||
album: album[];
|
||||
@@ -232,7 +238,7 @@ export type PingResponse = {
|
||||
serverVersion: string;
|
||||
};
|
||||
|
||||
type Search3Response = SubsonicResponse & {
|
||||
export type Search3Response = SubsonicResponse & {
|
||||
searchResult3: {
|
||||
artist: artist[];
|
||||
album: album[];
|
||||
@@ -246,12 +252,12 @@ export function isError(
|
||||
return (subsonicResponse as SubsonicError).error !== undefined;
|
||||
}
|
||||
|
||||
type IdName = {
|
||||
export type IdName = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
|
||||
export const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
|
||||
pipe(
|
||||
coverArt,
|
||||
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,
|
||||
name: song.title,
|
||||
encoding: pipe(
|
||||
customPlayers.encodingFor({ mimeType: song.contentType }),
|
||||
O.getOrElse(() => ({
|
||||
player: DEFAULT_CLIENT_APPLICATION,
|
||||
mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType
|
||||
mimeType: song.transcodedContentType
|
||||
? song.transcodedContentType
|
||||
: song.contentType,
|
||||
}))
|
||||
),
|
||||
duration: song.duration || 0,
|
||||
number: song.track || 0,
|
||||
genre: maybeAsGenre(song.genre),
|
||||
coverArt: coverArtURN(song.coverArt),
|
||||
album,
|
||||
artist: {
|
||||
id: song.artistId,
|
||||
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,
|
||||
name: album.name,
|
||||
year: album.year,
|
||||
@@ -326,19 +345,14 @@ const asAlbum = (album: album): Album => ({
|
||||
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) => ({
|
||||
id: b64Encode(genreName),
|
||||
name: genreName,
|
||||
});
|
||||
|
||||
const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
||||
export const maybeAsGenre = (
|
||||
genreName: string | undefined
|
||||
): Genre | undefined =>
|
||||
pipe(
|
||||
genreName,
|
||||
O.fromNullable,
|
||||
@@ -346,8 +360,12 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
||||
O.getOrElseW(() => undefined)
|
||||
);
|
||||
|
||||
export const asYear = (year: string) => ({
|
||||
year: year,
|
||||
});
|
||||
|
||||
export interface CustomPlayers {
|
||||
encodingFor({ mimeType }: { mimeType: string }): O.Option<Encoding>
|
||||
encodingFor({ mimeType }: { mimeType: string }): O.Option<Encoding>;
|
||||
}
|
||||
|
||||
export type CustomClient = {
|
||||
@@ -374,24 +392,25 @@ export class TranscodingCustomPlayers implements CustomPlayers {
|
||||
return new TranscodingCustomPlayers(new Map(parts));
|
||||
}
|
||||
|
||||
encodingFor = ({ mimeType }: { mimeType: string }): O.Option<Encoding> => pipe(
|
||||
encodingFor = ({ mimeType }: { mimeType: string }): O.Option<Encoding> =>
|
||||
pipe(
|
||||
this.transcodings.get(mimeType),
|
||||
O.fromNullable,
|
||||
O.map(transcodedMimeType => ({
|
||||
player:`${DEFAULT_CLIENT_APPLICATION}+${mimeType}`,
|
||||
mimeType: transcodedMimeType
|
||||
O.map((transcodedMimeType) => ({
|
||||
player: `${DEFAULT_CLIENT_APPLICATION}+${mimeType}`,
|
||||
mimeType: transcodedMimeType,
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const NO_CUSTOM_PLAYERS: CustomPlayers = {
|
||||
encodingFor(_) {
|
||||
return O.none
|
||||
return O.none;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_CLIENT_APPLICATION = "bonob";
|
||||
const USER_AGENT = "bonob";
|
||||
export const DEFAULT_CLIENT_APPLICATION = "bonob";
|
||||
export const USER_AGENT = "bonob";
|
||||
|
||||
export const asURLSearchParams = (q: any) => {
|
||||
const urlSearchParams = new URLSearchParams();
|
||||
@@ -406,7 +425,7 @@ export const asURLSearchParams = (q: any) => {
|
||||
export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>;
|
||||
|
||||
export const cachingImageFetcher =
|
||||
(cacheDir: string, delegate: ImageFetcher) =>
|
||||
(cacheDir: string, delegate: ImageFetcher, makeSharp = sharp) =>
|
||||
async (url: string): Promise<CoverArt | undefined> => {
|
||||
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
|
||||
return fse
|
||||
@@ -415,7 +434,7 @@ export const cachingImageFetcher =
|
||||
.catch(() =>
|
||||
delegate(url).then((image) => {
|
||||
if (image) {
|
||||
return sharp(image.data)
|
||||
return makeSharp(image.data)
|
||||
.png()
|
||||
.toBuffer()
|
||||
.then((png) => {
|
||||
@@ -446,6 +465,7 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
||||
alphabeticalByArtist: "alphabeticalByArtist",
|
||||
alphabeticalByName: "alphabeticalByName",
|
||||
byGenre: "byGenre",
|
||||
byYear: "byYear",
|
||||
random: "random",
|
||||
recentlyPlayed: "recent",
|
||||
mostPlayed: "frequent",
|
||||
@@ -464,17 +484,11 @@ type SubsonicCredentials = Credentials & {
|
||||
|
||||
export const asToken = (credentials: SubsonicCredentials) =>
|
||||
b64Encode(JSON.stringify(credentials));
|
||||
|
||||
export const parseToken = (token: string): SubsonicCredentials =>
|
||||
JSON.parse(b64Decode(token));
|
||||
|
||||
interface SubsonicMusicLibrary extends MusicLibrary {
|
||||
flavour(): string;
|
||||
bearerToken(
|
||||
credentials: Credentials
|
||||
): TE.TaskEither<Error, string | undefined>;
|
||||
}
|
||||
|
||||
export class Subsonic implements MusicService {
|
||||
export class Subsonic {
|
||||
url: URLBuilder;
|
||||
customPlayers: CustomPlayers;
|
||||
externalImageFetcher: ImageFetcher;
|
||||
@@ -489,7 +503,7 @@ export class Subsonic implements MusicService {
|
||||
this.externalImageFetcher = externalImageFetcher;
|
||||
}
|
||||
|
||||
get = async (
|
||||
private get = async (
|
||||
{ username, password }: Credentials,
|
||||
path: string,
|
||||
q: {} = {},
|
||||
@@ -518,7 +532,9 @@ export class Subsonic implements MusicService {
|
||||
} 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,
|
||||
path: string,
|
||||
q: {} = {}
|
||||
@@ -531,40 +547,16 @@ export class Subsonic implements MusicService {
|
||||
else return json as unknown as T;
|
||||
});
|
||||
|
||||
generateToken = (credentials: Credentials) =>
|
||||
pipe(
|
||||
ping = (credentials: Credentials): TE.TaskEither<AuthFailure, { authenticated: Boolean, type: string}> =>
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
this.getJSON<PingResponse>(
|
||||
_.pick(credentials, "username", "password"),
|
||||
"/rest/ping.view"
|
||||
),
|
||||
() => this.getJSON<PingResponse>(credentials, "/rest/ping.view")
|
||||
.then(it => ({
|
||||
authenticated: it.status == "ok",
|
||||
type: it.type
|
||||
})),
|
||||
(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 = (
|
||||
credentials: Credentials
|
||||
@@ -583,6 +575,7 @@ export class Subsonic implements MusicService {
|
||||
}))
|
||||
);
|
||||
|
||||
// todo: should be getArtistInfo2?
|
||||
getArtistInfo = (
|
||||
credentials: Credentials,
|
||||
id: string
|
||||
@@ -606,29 +599,42 @@ export class Subsonic implements MusicService {
|
||||
m: it.mediumImageUrl,
|
||||
l: it.largeImageUrl,
|
||||
},
|
||||
//todo: this does seem to be in OpenSubsonic?? it is also singular
|
||||
similarArtist: (it.similarArtist || []).map((artist) => ({
|
||||
id: `${artist.id}`,
|
||||
name: artist.name,
|
||||
// todo: whats this inLibrary used for? it probably should be filtered on??
|
||||
inLibrary: artistIsInLibrary(artist.id),
|
||||
image: artistImageURN({
|
||||
artistId: artist.id,
|
||||
artistImageURL: artist.artistImageUrl,
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
getAlbum = (credentials: Credentials, id: string): Promise<Album> =>
|
||||
this.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", { id })
|
||||
.then((it) => it.album)
|
||||
.then((album) => ({
|
||||
.then((album) => {
|
||||
const x: AlbumSummary = {
|
||||
id: album.id,
|
||||
name: album.name,
|
||||
year: album.year,
|
||||
genre: maybeAsGenre(album.genre),
|
||||
artistId: album.artistId,
|
||||
artistName: album.artist,
|
||||
coverArt: coverArtURN(album.coverArt),
|
||||
}));
|
||||
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 = (
|
||||
credentials: Credentials,
|
||||
@@ -647,26 +653,6 @@ export class Subsonic implements MusicService {
|
||||
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) =>
|
||||
this.get(credentials, "/rest/getCoverArt", size ? { id, size } : { id }, {
|
||||
headers: { "User-Agent": "bonob" },
|
||||
@@ -680,7 +666,7 @@ export class Subsonic implements MusicService {
|
||||
.then((it) => it.song)
|
||||
.then((song) =>
|
||||
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", {
|
||||
type: AlbumQueryTypeToSubsonicType[q.type],
|
||||
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
|
||||
...(q.fromYear ? { fromYear: q.fromYear } : {}),
|
||||
...(q.toYear ? { toYear: q.toYear } : {}),
|
||||
size: 500,
|
||||
offset: q._index,
|
||||
})
|
||||
@@ -730,111 +718,50 @@ export class Subsonic implements MusicService {
|
||||
total: albums.length == 500 ? total : q._index + albums.length,
|
||||
}));
|
||||
|
||||
// getStarred2 = (credentials: Credentials): Promise<{ albums: Album[] }> =>
|
||||
// this.getJSON<GetStarredResponse>(credentials, "/rest/getStarred2")
|
||||
// .then((it) => it.starred2)
|
||||
// .then((it) => ({
|
||||
// albums: it.album.map(asAlbum),
|
||||
// }));
|
||||
|
||||
login = async (token: string) => this.libraryFor(parseToken(token));
|
||||
|
||||
private libraryFor = (
|
||||
credentials: Credentials & { type: string }
|
||||
): Promise<SubsonicMusicLibrary> => {
|
||||
const subsonic = this;
|
||||
|
||||
const genericSubsonic: SubsonicMusicLibrary = {
|
||||
flavour: () => "subsonic",
|
||||
bearerToken: (_: Credentials) => TE.right(undefined),
|
||||
artists: (q: ArtistQuery): Promise<Result<ArtistSummary>> =>
|
||||
subsonic
|
||||
.getArtists(credentials)
|
||||
.then(slice2(q))
|
||||
.then(([page, total]) => ({
|
||||
total,
|
||||
results: page.map((it) => ({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
image: it.image,
|
||||
})),
|
||||
})),
|
||||
artist: async (id: string): Promise<Artist> =>
|
||||
subsonic.getArtistWithInfo(credentials, id),
|
||||
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) =>
|
||||
getGenres = (credentials: Credentials) =>
|
||||
this.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,
|
||||
}
|
||||
A.map(maybeAsGenre),
|
||||
A.filter((it) => it != undefined)
|
||||
)
|
||||
);
|
||||
}
|
||||
if (track.rating.stars != rating.stars) {
|
||||
thingsToUpdate.push(
|
||||
subsonic.getJSON(credentials, `/rest/setRating`, {
|
||||
id: trackId,
|
||||
rating: rating.stars,
|
||||
})
|
||||
|
||||
private st4r = (credentials: Credentials, action: string, { id } : { id: string }) =>
|
||||
this.getJSON<SubsonicResponse>(credentials, `/rest/${action}`, { id }).then(it =>
|
||||
it.status == "ok"
|
||||
);
|
||||
}
|
||||
return Promise.all(thingsToUpdate);
|
||||
|
||||
star = (credentials: Credentials, ids : { id: string }) =>
|
||||
this.st4r(credentials, "star", ids)
|
||||
|
||||
unstar = (credentials: Credentials, ids : { id: string }) =>
|
||||
this.st4r(credentials, "unstar", ids)
|
||||
|
||||
setRating = (credentials: Credentials, id: string, rating: number) =>
|
||||
this.getJSON<SubsonicResponse>(credentials, `/rest/setRating`, {
|
||||
id,
|
||||
rating,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
stream: async ({
|
||||
trackId,
|
||||
range,
|
||||
}: {
|
||||
trackId: string;
|
||||
range: string | undefined;
|
||||
}) =>
|
||||
subsonic.getTrack(credentials, trackId).then((track) =>
|
||||
subsonic
|
||||
.get(
|
||||
.then(it => it.status == "ok");
|
||||
|
||||
scrobble = (credentials: Credentials, id: string, submission: boolean) =>
|
||||
this.getJSON<SubsonicResponse>(credentials, `/rest/scrobble`, {
|
||||
id,
|
||||
submission,
|
||||
})
|
||||
.then(it => it.status == "ok")
|
||||
|
||||
stream = (credentials: Credentials, id: string, c: string, range: string | undefined) =>
|
||||
this.get(
|
||||
credentials,
|
||||
`/rest/stream`,
|
||||
{
|
||||
id: trackId,
|
||||
c: track.encoding.player,
|
||||
id,
|
||||
c,
|
||||
},
|
||||
{
|
||||
headers: pipe(
|
||||
@@ -860,76 +787,22 @@ export class Subsonic implements MusicService {
|
||||
"accept-ranges": stream.headers["accept-ranges"],
|
||||
},
|
||||
stream: stream.data,
|
||||
}));
|
||||
|
||||
playlists = (credentials: Credentials) =>
|
||||
this.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
|
||||
.then(({ playlists }) => (playlists.playlist || []).map( it => ({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
coverArt: coverArtURN(it.coverArt),
|
||||
}))
|
||||
),
|
||||
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", {
|
||||
|
||||
playlist = (credentials: Credentials, id: string) =>
|
||||
this.getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", {
|
||||
id,
|
||||
})
|
||||
.then((it) => it.playlist)
|
||||
.then((playlist) => {
|
||||
.then(({ playlist }) => {
|
||||
let trackNumber = 1;
|
||||
return {
|
||||
id: playlist.id,
|
||||
@@ -952,114 +825,69 @@ export class Subsonic implements MusicService {
|
||||
number: trackNumber++,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
createPlaylist: async (name: string) =>
|
||||
subsonic
|
||||
.getJSON<GetPlaylistResponse>(credentials, "/rest/createPlaylist", {
|
||||
});
|
||||
|
||||
createPlayList = (credentials: Credentials, name: string) =>
|
||||
this.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", {
|
||||
.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((_) => true),
|
||||
addToPlaylist: async (playlistId: string, trackId: string) =>
|
||||
subsonic
|
||||
.getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", {
|
||||
.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,
|
||||
songIdToAdd: trackId,
|
||||
...changes
|
||||
})
|
||||
.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>(
|
||||
.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 || [])
|
||||
.then((songs) =>
|
||||
Promise.all(
|
||||
songs.map((song) =>
|
||||
subsonic
|
||||
.getAlbum(credentials, song.albumId!)
|
||||
.then((album) => asTrack(album, song, this.customPlayers))
|
||||
.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 }
|
||||
)
|
||||
)
|
||||
),
|
||||
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
|
||||
.getJSON<GetInternetRadioStationsResponse>(
|
||||
.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) => ({
|
||||
.then((stations) =>
|
||||
stations.map((it) => ({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
url: it.streamUrl,
|
||||
homePage: it.homePageUrl
|
||||
}))),
|
||||
radioStation: async (id: string) => genericSubsonic
|
||||
.radioStations()
|
||||
.then(it =>
|
||||
it.find(station => station.id === id)!
|
||||
),
|
||||
|
||||
};
|
||||
|
||||
if (credentials.type == "navidrome") {
|
||||
// todo: there does not seem to be a test for this??
|
||||
return Promise.resolve({
|
||||
...genericSubsonic,
|
||||
flavour: () => "navidrome",
|
||||
bearerToken: (credentials: Credentials) =>
|
||||
pipe(
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
axios.post(
|
||||
this.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)
|
||||
),
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve(genericSubsonic);
|
||||
}
|
||||
};
|
||||
}
|
||||
homePage: it.homePageUrl,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
320
src/subsonic_music_library.ts
Normal file
320
src/subsonic_music_library.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
27
src/utils.ts
27
src/utils.ts
@@ -1,3 +1,5 @@
|
||||
import { DOMParser, XMLSerializer, Node } from '@xmldom/xmldom';
|
||||
|
||||
export function takeWithRepeats<T>(things:T[], count: number) {
|
||||
const result = [];
|
||||
for(let i = 0; i < count; i++) {
|
||||
@@ -5,3 +7,28 @@ export function takeWithRepeats<T>(things:T[], count: number) {
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@ import {
|
||||
Album,
|
||||
Artist,
|
||||
Track,
|
||||
albumToAlbumSummary,
|
||||
artistToArtistSummary,
|
||||
PlaylistSummary,
|
||||
Playlist,
|
||||
SimilarArtist,
|
||||
AlbumSummary,
|
||||
RadioStation,
|
||||
} from "../src/music_service";
|
||||
ArtistSummary,
|
||||
TrackSummary
|
||||
} from "../src/music_library";
|
||||
|
||||
import { b64Encode } from "../src/b64";
|
||||
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 artist = {
|
||||
return {
|
||||
id,
|
||||
name: `Artist ${id}`,
|
||||
albums: [anAlbum(), anAlbum(), anAlbum()],
|
||||
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: [
|
||||
aSimilarArtist({ id: uuid(), name: "Similar artist1", 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 function aTrack(fields: Partial<Track> = {}): Track {
|
||||
export function aTrackSummary(fields: Partial<TrackSummary> = {}): TrackSummary {
|
||||
const id = uuid();
|
||||
const artist = anArtist();
|
||||
const artist = fields.artist || anArtistSummary();
|
||||
const genre = fields.genre || randomGenre();
|
||||
const rating = { love: false, stars: Math.floor(Math.random() * 5) };
|
||||
return {
|
||||
@@ -181,28 +194,53 @@ export function aTrack(fields: Partial<Track> = {}): Track {
|
||||
duration: randomInt(500),
|
||||
number: randomInt(100),
|
||||
genre,
|
||||
artist: artistToArtistSummary(artist),
|
||||
album: albumToAlbumSummary(
|
||||
anAlbum({ artistId: artist.id, artistName: artist.name, genre })
|
||||
),
|
||||
artist,
|
||||
coverArt: { system: "subsonic", resource: `art:${uuid()}`},
|
||||
rating,
|
||||
...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();
|
||||
return {
|
||||
id,
|
||||
name: `Album ${id}`,
|
||||
genre: randomGenre(),
|
||||
year: `19${randomInt(99)}`,
|
||||
genre: randomGenre(),
|
||||
coverArt: { system: "subsonic", resource: `art:${uuid()}` },
|
||||
artistId: `Artist ${uuid()}`,
|
||||
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,
|
||||
};
|
||||
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 {
|
||||
@@ -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_NAME = "Blondie";
|
||||
export const BLONDIE: Artist = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { left, right } from 'fp-ts/Either'
|
||||
|
||||
import { cryptoEncryption, jwsEncryption } from '../src/encryption';
|
||||
|
||||
describe("jwsEncryption", () => {
|
||||
@@ -7,7 +9,7 @@ describe("jwsEncryption", () => {
|
||||
const value = "bobs your uncle"
|
||||
const hash = e.encrypt(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", () => {
|
||||
@@ -29,7 +31,7 @@ describe("cryptoEncryption", () => {
|
||||
const value = "bobs your uncle"
|
||||
const hash = e.encrypt(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", () => {
|
||||
@@ -42,4 +44,10 @@ describe("cryptoEncryption", () => {
|
||||
|
||||
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"));
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
import libxmljs from "libxmljs2";
|
||||
import { FixedClock } from "../src/clock";
|
||||
import { xmlTidy } from "../src/utils";
|
||||
|
||||
import {
|
||||
contains,
|
||||
@@ -20,17 +20,17 @@ import {
|
||||
allOf,
|
||||
features,
|
||||
STAR_WARS,
|
||||
NO_FEATURES,
|
||||
} from "../src/icon";
|
||||
|
||||
describe("SvgIcon", () => {
|
||||
const xmlTidy = (xml: string) =>
|
||||
libxmljs.parseXmlString(xml, { noblanks: true, net: false }).toString();
|
||||
|
||||
const svgIcon24 = `<?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>
|
||||
`;
|
||||
|
||||
@@ -61,7 +61,9 @@ describe("SvgIcon", () => {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32">
|
||||
<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>
|
||||
`)
|
||||
);
|
||||
@@ -110,7 +112,9 @@ describe("SvgIcon", () => {
|
||||
<rect x="0" y="0" width="24" height="24" fill="red"/>
|
||||
<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>
|
||||
`)
|
||||
);
|
||||
@@ -134,7 +138,9 @@ describe("SvgIcon", () => {
|
||||
<rect x="-4" y="-4" width="36" height="36" fill="pink"/>
|
||||
<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>
|
||||
`)
|
||||
);
|
||||
@@ -152,7 +158,9 @@ describe("SvgIcon", () => {
|
||||
<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>
|
||||
`)
|
||||
);
|
||||
@@ -172,7 +180,9 @@ describe("SvgIcon", () => {
|
||||
<rect x="0" y="0" width="24" height="24" fill="red"/>
|
||||
<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>
|
||||
`)
|
||||
);
|
||||
@@ -182,7 +192,7 @@ describe("SvgIcon", () => {
|
||||
|
||||
describe("foreground color", () => {
|
||||
describe("with no viewPort increase", () => {
|
||||
it("should add a rectangle the same size as the original viewPort", () => {
|
||||
it("should change the fill values", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon24)
|
||||
.with({ features: { foregroundColor: "red" } })
|
||||
@@ -192,7 +202,9 @@ describe("SvgIcon", () => {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="path1" fill="red"/>
|
||||
<path d="path2" fill="none" stroke="red"/>
|
||||
<text font-size="25" fill="none" stroke="red">80's</text>
|
||||
<path d="path3" fill="red"/>
|
||||
<text font-size="25" fill="red">80's</text>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
@@ -200,7 +212,7 @@ describe("SvgIcon", () => {
|
||||
});
|
||||
|
||||
describe("with a viewPort increase", () => {
|
||||
it("should add a rectangle the same size as the original viewPort", () => {
|
||||
it("should change the fill values", () => {
|
||||
expect(
|
||||
new SvgIcon(svgIcon24)
|
||||
.with({
|
||||
@@ -215,7 +227,9 @@ describe("SvgIcon", () => {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32">
|
||||
<path d="path1" fill="pink"/>
|
||||
<path d="path2" fill="none" stroke="pink"/>
|
||||
<text font-size="25" fill="none" stroke="pink">80's</text>
|
||||
<path d="path3" fill="pink"/>
|
||||
<text font-size="25" fill="pink">80's</text>
|
||||
</svg>
|
||||
`)
|
||||
);
|
||||
@@ -233,7 +247,9 @@ describe("SvgIcon", () => {
|
||||
<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>
|
||||
`)
|
||||
);
|
||||
@@ -252,7 +268,9 @@ describe("SvgIcon", () => {
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="path1" fill="red"/>
|
||||
<path d="path2" fill="none" stroke="red"/>
|
||||
<text font-size="25" fill="none" stroke="red">80's</text>
|
||||
<path d="path3" fill="red"/>
|
||||
<text font-size="25" fill="red">80's</text>
|
||||
</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("with no other changes", () => {
|
||||
it("should swap out the svg, but maintain the IconFeatures", () => {
|
||||
@@ -318,10 +378,14 @@ describe("SvgIcon", () => {
|
||||
|
||||
class DummyIcon implements Icon {
|
||||
svg: string;
|
||||
features: Partial<IconFeatures>;
|
||||
features: IconFeatures;
|
||||
|
||||
constructor(svg: string, features: Partial<IconFeatures>) {
|
||||
this.svg = svg;
|
||||
this.features = features;
|
||||
this.features = {
|
||||
...NO_FEATURES,
|
||||
...features
|
||||
};
|
||||
}
|
||||
|
||||
public apply = (transformer: Transformer): Icon => transformer(this);
|
||||
@@ -350,6 +414,7 @@ describe("transform", () => {
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "blue",
|
||||
text: "a",
|
||||
},
|
||||
})
|
||||
.apply(
|
||||
@@ -357,6 +422,7 @@ describe("transform", () => {
|
||||
features: {
|
||||
foregroundColor: "override1",
|
||||
backgroundColor: "override2",
|
||||
text: "b",
|
||||
},
|
||||
})
|
||||
) as DummyIcon;
|
||||
@@ -366,6 +432,7 @@ describe("transform", () => {
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "override1",
|
||||
backgroundColor: "override2",
|
||||
text: "b",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -382,6 +449,7 @@ describe("transform", () => {
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "blue",
|
||||
text: "bob",
|
||||
},
|
||||
})
|
||||
.apply(
|
||||
@@ -395,6 +463,7 @@ describe("transform", () => {
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "blue",
|
||||
text: "bob"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -411,6 +480,7 @@ describe("features", () => {
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "blue",
|
||||
text: "foobar"
|
||||
})
|
||||
) as DummyIcon;
|
||||
|
||||
@@ -418,6 +488,7 @@ describe("features", () => {
|
||||
viewPortIncreasePercent: 100,
|
||||
foregroundColor: "blue",
|
||||
backgroundColor: "blue",
|
||||
text: "foobar"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,7 @@ import { InMemoryMusicService } from "./in_memory_music_service";
|
||||
import {
|
||||
MusicLibrary,
|
||||
artistToArtistSummary,
|
||||
albumToAlbumSummary,
|
||||
} from "../src/music_service";
|
||||
} from "../src/music_library";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import {
|
||||
anArtist,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
METAL,
|
||||
HIP_HOP,
|
||||
SKA,
|
||||
anAlbumSummary,
|
||||
} from "./builders";
|
||||
import _ from "underscore";
|
||||
|
||||
@@ -167,23 +167,6 @@ describe("InMemoryMusicService", () => {
|
||||
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("when it exists", () => {
|
||||
it("should return the track", async () => {
|
||||
@@ -194,16 +177,16 @@ describe("InMemoryMusicService", () => {
|
||||
});
|
||||
|
||||
describe("albums", () => {
|
||||
const artist1_album1 = anAlbum({ genre: POP });
|
||||
const artist1_album2 = anAlbum({ genre: ROCK });
|
||||
const artist1_album3 = anAlbum({ genre: METAL });
|
||||
const artist1_album4 = anAlbum({ genre: POP });
|
||||
const artist1_album5 = anAlbum({ genre: POP });
|
||||
const artist1_album1 = anAlbumSummary({ genre: POP });
|
||||
const artist1_album2 = anAlbumSummary({ genre: ROCK });
|
||||
const artist1_album3 = anAlbumSummary({ genre: METAL });
|
||||
const artist1_album4 = anAlbumSummary({ 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_album2 = anAlbum({ genre: POP });
|
||||
const artist3_album1 = anAlbumSummary({ genre: HIP_HOP });
|
||||
const artist3_album2 = anAlbumSummary({ genre: POP });
|
||||
|
||||
const artist1 = anArtist({
|
||||
name: "artist1",
|
||||
@@ -212,8 +195,8 @@ describe("InMemoryMusicService", () => {
|
||||
artist1_album2,
|
||||
artist1_album3,
|
||||
artist1_album4,
|
||||
artist1_album5,
|
||||
],
|
||||
artist1_album5
|
||||
]
|
||||
});
|
||||
const artist2 = anArtist({ name: "artist2", albums: [artist2_album1] });
|
||||
const artist3 = anArtist({
|
||||
@@ -275,16 +258,16 @@ describe("InMemoryMusicService", () => {
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album1),
|
||||
albumToAlbumSummary(artist1_album2),
|
||||
albumToAlbumSummary(artist1_album3),
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
artist1_album1,
|
||||
artist1_album2,
|
||||
artist1_album3,
|
||||
artist1_album4,
|
||||
artist1_album5,
|
||||
|
||||
albumToAlbumSummary(artist2_album1),
|
||||
artist2_album1,
|
||||
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
artist3_album1,
|
||||
artist3_album2,
|
||||
],
|
||||
total: totalAlbumCount,
|
||||
});
|
||||
@@ -300,7 +283,7 @@ describe("InMemoryMusicService", () => {
|
||||
type: "alphabeticalByName",
|
||||
})
|
||||
).toEqual({
|
||||
results: _.sortBy(allAlbums, "name").map(albumToAlbumSummary),
|
||||
results: _.sortBy(allAlbums, "name"),
|
||||
total: totalAlbumCount,
|
||||
});
|
||||
});
|
||||
@@ -317,9 +300,9 @@ describe("InMemoryMusicService", () => {
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
albumToAlbumSummary(artist2_album1),
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
artist1_album5,
|
||||
artist2_album1,
|
||||
artist3_album1,
|
||||
],
|
||||
total: totalAlbumCount,
|
||||
});
|
||||
@@ -336,8 +319,8 @@ describe("InMemoryMusicService", () => {
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
artist3_album1,
|
||||
artist3_album2,
|
||||
],
|
||||
total: totalAlbumCount,
|
||||
});
|
||||
@@ -357,10 +340,10 @@ describe("InMemoryMusicService", () => {
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album1),
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
artist1_album1,
|
||||
artist1_album4,
|
||||
artist1_album5,
|
||||
artist3_album2,
|
||||
],
|
||||
total: 4,
|
||||
});
|
||||
@@ -379,8 +362,8 @@ describe("InMemoryMusicService", () => {
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
artist1_album4,
|
||||
artist1_album5,
|
||||
],
|
||||
total: 4,
|
||||
});
|
||||
@@ -397,7 +380,7 @@ describe("InMemoryMusicService", () => {
|
||||
_count: 100,
|
||||
})
|
||||
).toEqual({
|
||||
results: [albumToAlbumSummary(artist3_album2)],
|
||||
results: [artist3_album2],
|
||||
total: 4,
|
||||
});
|
||||
});
|
||||
@@ -424,7 +407,10 @@ describe("InMemoryMusicService", () => {
|
||||
describe("when it exists", () => {
|
||||
it("should provide an album", async () => {
|
||||
expect(await musicLibrary.album(artist1_album5.id)).toEqual(
|
||||
artist1_album5
|
||||
{
|
||||
...artist1_album5,
|
||||
tracks: []
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,11 +19,10 @@ import {
|
||||
slice2,
|
||||
asResult,
|
||||
artistToArtistSummary,
|
||||
albumToAlbumSummary,
|
||||
Track,
|
||||
Genre,
|
||||
Rating,
|
||||
} from "../src/music_service";
|
||||
} from "../src/music_library";
|
||||
import { BUrn } from "../src/burn";
|
||||
|
||||
export class InMemoryMusicService implements MusicService {
|
||||
@@ -97,14 +96,13 @@ export class InMemoryMusicService implements MusicService {
|
||||
}
|
||||
})
|
||||
.then((matches) => matches.map((it) => it.album))
|
||||
.then((it) => it.map(albumToAlbumSummary))
|
||||
.then(slice2(q))
|
||||
.then(asResult),
|
||||
album: (id: string) =>
|
||||
pipe(
|
||||
this.artists.flatMap((it) => it.albums).find((it) => it.id === id),
|
||||
O.fromNullable,
|
||||
O.map((it) => Promise.resolve(it)),
|
||||
O.map((it) => Promise.resolve({ ...it, tracks: [] })),
|
||||
O.getOrElse(() => Promise.reject(`No album with id '${id}'`))
|
||||
),
|
||||
genres: () =>
|
||||
@@ -119,12 +117,6 @@ export class InMemoryMusicService implements MusicService {
|
||||
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),
|
||||
track: (trackId: string) =>
|
||||
pipe(
|
||||
@@ -163,6 +155,7 @@ export class InMemoryMusicService implements MusicService {
|
||||
topSongs: async (_: string) => Promise.resolve([]),
|
||||
radioStations: async () => Promise.resolve([]),
|
||||
radioStation: async (_: string) => Promise.reject("Unsupported operation"),
|
||||
years: async () => Promise.resolve([]),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { anArtist } from "./builders";
|
||||
import { artistToArtistSummary } from "../src/music_service";
|
||||
import { artistToArtistSummary } from "../src/music_library";
|
||||
|
||||
describe("artistToArtistSummary", () => {
|
||||
it("should map fields correctly", () => {
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "./builders";
|
||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||
import { InMemoryLinkCodes } from "../src/link_codes";
|
||||
import { Credentials } from "../src/music_service";
|
||||
import { Credentials } from "../src/music_library";
|
||||
import makeServer from "../src/server";
|
||||
import { Service, bonobService, Sonos } from "../src/sonos";
|
||||
import supersoap from "./supersoap";
|
||||
|
||||
@@ -4,7 +4,7 @@ import request from "supertest";
|
||||
import Image from "image-js";
|
||||
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, {
|
||||
BONOB_ACCESS_TOKEN_HEADER,
|
||||
RangeBytesFromFilter,
|
||||
@@ -1366,11 +1366,25 @@ describe("server", () => {
|
||||
"..%2F..%2Ffoo",
|
||||
"%2Fetc%2Fpasswd",
|
||||
".%2Fbob.js",
|
||||
".",
|
||||
"..",
|
||||
"1",
|
||||
"%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:withSomeText"
|
||||
].forEach((type) => {
|
||||
describe(`trying to retrieve an icon with name ${type}`, () => {
|
||||
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", () => {
|
||||
[
|
||||
"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>`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
PUNK,
|
||||
aPlaylist,
|
||||
aRadioStation,
|
||||
anArtistSummary,
|
||||
anAlbumSummary,
|
||||
} from "./builders";
|
||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||
import supersoap from "./supersoap";
|
||||
@@ -49,7 +51,7 @@ import {
|
||||
artistToArtistSummary,
|
||||
MusicService,
|
||||
playlistToPlaylistSummary,
|
||||
} from "../src/music_service";
|
||||
} from "../src/music_library";
|
||||
import { APITokens } from "../src/api_tokens";
|
||||
import dayjs from "dayjs";
|
||||
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", () => {
|
||||
const musicService = {
|
||||
generateToken: jest.fn(),
|
||||
@@ -575,6 +595,8 @@ describe("wsdl api", () => {
|
||||
artists: jest.fn(),
|
||||
artist: jest.fn(),
|
||||
genres: jest.fn(),
|
||||
years: jest.fn(),
|
||||
year: jest.fn(),
|
||||
playlists: jest.fn(),
|
||||
playlist: jest.fn(),
|
||||
album: jest.fn(),
|
||||
@@ -1153,6 +1175,12 @@ describe("wsdl api", () => {
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "years",
|
||||
title: "Years",
|
||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "recentlyAdded",
|
||||
title: "Recently added",
|
||||
@@ -1247,6 +1275,12 @@ describe("wsdl api", () => {
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "years",
|
||||
title: "Jaren",
|
||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "recentlyAdded",
|
||||
title: "Onlangs toegevoegd",
|
||||
@@ -1324,7 +1358,7 @@ describe("wsdl api", () => {
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: expectedGenres.map((genre) => ({
|
||||
itemType: "container",
|
||||
itemType: "albumList",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
albumArtURI: iconArtURI(
|
||||
@@ -1349,7 +1383,7 @@ describe("wsdl api", () => {
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: [PUNK, ROCK].map((genre) => ({
|
||||
itemType: "container",
|
||||
itemType: "albumList",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
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", () => {
|
||||
const playlist1 = aPlaylist({ id: "1", name: "pl1", entries: []});
|
||||
const playlist2 = aPlaylist({ id: "2", name: "pl2", entries: []});
|
||||
@@ -2260,10 +2358,8 @@ describe("wsdl api", () => {
|
||||
});
|
||||
|
||||
describe("asking for an album", () => {
|
||||
const album = anAlbum();
|
||||
const artist = anArtist({
|
||||
albums: [album],
|
||||
});
|
||||
const album = anAlbumSummary();
|
||||
const artist = anArtistSummary();
|
||||
|
||||
const track1 = aTrack({ artist, album, number: 1 });
|
||||
const track2 = aTrack({ artist, album, number: 2 });
|
||||
@@ -2274,7 +2370,12 @@ describe("wsdl api", () => {
|
||||
const tracks = [track1, track2, track3, track4, track5];
|
||||
|
||||
beforeEach(() => {
|
||||
musicLibrary.tracks.mockResolvedValue(tracks);
|
||||
musicLibrary.album.mockResolvedValue(anAlbum({
|
||||
...album,
|
||||
artistName: artist.name,
|
||||
artistId: artist.id,
|
||||
tracks
|
||||
}));
|
||||
});
|
||||
|
||||
describe("asking for all for an album", () => {
|
||||
@@ -2298,7 +2399,7 @@ describe("wsdl api", () => {
|
||||
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,
|
||||
})
|
||||
);
|
||||
expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id);
|
||||
expect(musicLibrary.album).toHaveBeenCalledWith(album.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
3950
tests/subsonic_music_library.test.ts
Normal file
3950
tests/subsonic_music_library.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
3
web/icons/yy.svg
Normal file
3
web/icons/yy.svg
Normal 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
3
web/icons/yyyy.svg
Normal 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 |
Reference in New Issue
Block a user