mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-22 09:53:32 +01:00
Compare commits
39 Commits
feature/nd
...
v0.6.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9786d9f1dd | ||
|
|
a9d88bd9eb | ||
|
|
f6fc7ab920 | ||
|
|
8111041551 | ||
|
|
df2ef9b152 | ||
|
|
33473cd387 | ||
|
|
7f743aaa7e | ||
|
|
d4bed77c54 | ||
|
|
29531a6e01 | ||
|
|
e78b6c4fbc | ||
|
|
2941f6f595 | ||
|
|
2c48d08b0e | ||
|
|
de48ee0fca | ||
|
|
cefdf5e2d5 | ||
|
|
f86a78b338 | ||
|
|
4d23885d7c | ||
|
|
8c80c00089 | ||
|
|
ebf385e918 | ||
|
|
a20fdcbc5f | ||
|
|
f763dbd8b9 | ||
|
|
2d3e5dc635 | ||
|
|
6091308266 | ||
|
|
fed6e9663d | ||
|
|
03b5b04c73 | ||
|
|
4a529b46e1 | ||
|
|
5c9fbede7a | ||
|
|
94e25e03ea | ||
|
|
d9c3a3edcb | ||
|
|
f22b094d83 | ||
|
|
4ae71675e8 | ||
|
|
84866dfd60 | ||
|
|
719fd998b1 | ||
|
|
91995678a4 | ||
|
|
67d6c4a730 | ||
|
|
3df4f4daa7 | ||
|
|
bd63408ec3 | ||
|
|
da5491b474 | ||
|
|
bbd676b5b8 | ||
|
|
d01c747c96 |
16
.devcontainer/Dockerfile
Normal file
16
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:16-bullseye
|
||||||
|
|
||||||
|
LABEL maintainer=simojenki
|
||||||
|
|
||||||
|
ENV JEST_TIMEOUT=60000
|
||||||
|
EXPOSE 4534
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get -y upgrade && \
|
||||||
|
apt-get -y install --no-install-recommends \
|
||||||
|
libvips-dev \
|
||||||
|
python3 \
|
||||||
|
make \
|
||||||
|
git \
|
||||||
|
g++ \
|
||||||
|
vim
|
||||||
19
.devcontainer/devcontainer.json
Normal file
19
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "bonob",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile"
|
||||||
|
},
|
||||||
|
"containerEnv": {
|
||||||
|
// these env vars need to be configured appropriately for your local dev env
|
||||||
|
"BNB_DEV_SONOS_DEVICE_IP": "${localEnv:BNB_DEV_SONOS_DEVICE_IP}",
|
||||||
|
"BNB_DEV_HOST_IP": "${localEnv:BNB_DEV_HOST_IP}",
|
||||||
|
"BNB_DEV_SUBSONIC_URL": "${localEnv:BNB_DEV_SUBSONIC_URL}"
|
||||||
|
},
|
||||||
|
"remoteUser": "node",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/docker-in-docker:1": {
|
||||||
|
"version": "latest",
|
||||||
|
"moby": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.devcontainer
|
||||||
|
.github
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/install-state.gz
|
||||||
|
build
|
||||||
|
node_modules
|
||||||
32
.github/workflows/ci.yml
vendored
32
.github/workflows/ci.yml
vendored
@@ -17,9 +17,9 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Check out the repo
|
name: Check out the repo
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
-
|
-
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
-
|
-
|
||||||
@@ -29,37 +29,47 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
push_to_registry:
|
push_to_registry:
|
||||||
name: Push Docker image to Docker Hub
|
name: Push Docker image to Docker registries
|
||||||
needs: build_and_test
|
needs: build_and_test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Check out the repo
|
name: Check out the repo
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v1
|
uses: docker/setup-qemu-action@v2
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v3
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: simojenki/bonob
|
images: |
|
||||||
|
simojenki/bonob
|
||||||
|
ghcr.io/simojenki/bonob
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
-
|
-
|
||||||
name: Push to Docker Hub
|
name: Log in to GitHub Container registry
|
||||||
uses: docker/build-push-action@v2
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
-
|
||||||
|
name: Push image
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
|
|||||||
147529
.yarn/releases/yarn-1.22.19.cjs
vendored
Executable file
147529
.yarn/releases/yarn-1.22.19.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
631
.yarn/releases/yarn-berry.cjs
vendored
631
.yarn/releases/yarn-berry.cjs
vendored
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
|||||||
nodeLinker: node-modules
|
nodeLinker: node-modules
|
||||||
|
|
||||||
yarnPath: .yarn/releases/yarn-berry.cjs
|
yarnPath: .yarn/releases/yarn-1.22.19.cjs
|
||||||
|
|||||||
33
Dockerfile
33
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:16-bullseye as build
|
FROM node:16-bullseye-slim as build
|
||||||
|
|
||||||
WORKDIR /bonob
|
WORKDIR /bonob
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ COPY yarn.lock .
|
|||||||
COPY .yarnrc.yml .
|
COPY .yarnrc.yml .
|
||||||
COPY .yarn/releases ./.yarn/releases
|
COPY .yarn/releases ./.yarn/releases
|
||||||
|
|
||||||
ENV JEST_TIMEOUT=30000
|
ENV JEST_TIMEOUT=60000
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
@@ -29,13 +29,29 @@ RUN apt-get update && \
|
|||||||
g++ && \
|
g++ && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
yarn install --immutable && \
|
yarn config set network-timeout 600000 -g && \
|
||||||
yarn gitinfo && \
|
yarn install \
|
||||||
|
--prefer-offline \
|
||||||
|
--frozen-lockfile \
|
||||||
|
--non-interactive \
|
||||||
|
--production=false && \
|
||||||
yarn test --no-cache && \
|
yarn test --no-cache && \
|
||||||
yarn build
|
yarn gitinfo && \
|
||||||
|
yarn build && \
|
||||||
|
rm -Rf node_modules && \
|
||||||
|
NODE_ENV=production yarn install \
|
||||||
|
--prefer-offline \
|
||||||
|
--pure-lockfile \
|
||||||
|
--non-interactive \
|
||||||
|
--production=true
|
||||||
|
|
||||||
|
|
||||||
FROM node:16-bullseye
|
FROM node:16-bullseye-slim
|
||||||
|
|
||||||
|
LABEL maintainer="simojenki" \
|
||||||
|
org.opencontainers.image.source="https://github.com/simojenki/bonob" \
|
||||||
|
org.opencontainers.image.description="bonob SONOS SMAPI implementation" \
|
||||||
|
org.opencontainers.image.licenses="GPLv3"
|
||||||
|
|
||||||
ENV BNB_PORT=4534
|
ENV BNB_PORT=4534
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
@@ -56,7 +72,10 @@ COPY src/Sonoswsdl-1.19.4-20190411.142401-3.wsdl ./src/Sonoswsdl-1.19.4-20190411
|
|||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get -y upgrade && \
|
apt-get -y upgrade && \
|
||||||
apt-get -y install --no-install-recommends libvips tzdata && \
|
apt-get -y install --no-install-recommends \
|
||||||
|
libvips \
|
||||||
|
tzdata \
|
||||||
|
wget && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -16,7 +16,7 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
|
|||||||
- Search by Album, Artist, Track
|
- Search by Album, Artist, Track
|
||||||
- Playlist editing through sonos app.
|
- Playlist editing through sonos app.
|
||||||
- Marking of songs as favourites and with ratings through the sonos app.
|
- Marking of songs as favourites and with ratings through the sonos app.
|
||||||
- Localization (only en-US & nl-NL supported currently, require translations for other languages). [Sonos localization and supported languages](https://developer.sonos.com/build/content-service-add-features/strings-and-localization/)
|
- Localization (only en-US, da-DK & nl-NL supported currently, require translations for other languages). [Sonos localization and supported languages](https://developer.sonos.com/build/content-service-add-features/strings-and-localization/)
|
||||||
- Auto discovery of sonos devices
|
- Auto discovery of sonos devices
|
||||||
- Discovery of sonos devices using seed IP address
|
- Discovery of sonos devices using seed IP address
|
||||||
- Auto registration with sonos on start
|
- Auto registration with sonos on start
|
||||||
@@ -25,7 +25,23 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
|
|||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
bonob is distributed via docker and can be run in a number of ways
|
bonob is packaged as an OCI image to both the docker hub registry and github registry.
|
||||||
|
|
||||||
|
ie.
|
||||||
|
```bash
|
||||||
|
docker pull docker.io/simojenki/bonob
|
||||||
|
```
|
||||||
|
or
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/simojenki/bonob
|
||||||
|
```
|
||||||
|
|
||||||
|
tag | description
|
||||||
|
--- | ---
|
||||||
|
latest | Latest release, intended to be stable
|
||||||
|
master | Laster build from master, probably works, however is currently under test in
|
||||||
|
vX.Y.Z | Fixed release versions from tags, for those that want to pin to specific release
|
||||||
|
|
||||||
|
|
||||||
### Full sonos device auto-discovery and auto-registration using docker --network host
|
### Full sonos device auto-discovery and auto-registration using docker --network host
|
||||||
|
|
||||||
@@ -126,8 +142,8 @@ services:
|
|||||||
# ip address of your machine running bonob
|
# ip address of your machine running bonob
|
||||||
BNB_URL: http://192.168.1.111:4534
|
BNB_URL: http://192.168.1.111:4534
|
||||||
BNB_SECRET: changeme
|
BNB_SECRET: changeme
|
||||||
BNB_SONOS_AUTO_REGISTER: true
|
BNB_SONOS_AUTO_REGISTER: "true"
|
||||||
BNB_SONOS_DEVICE_DISCOVERY: true
|
BNB_SONOS_DEVICE_DISCOVERY: "true"
|
||||||
BNB_SONOS_SERVICE_ID: 246
|
BNB_SONOS_SERVICE_ID: 246
|
||||||
# ip address of one of your sonos devices
|
# ip address of one of your sonos devices
|
||||||
BNB_SONOS_SEED_HOST: 192.168.1.121
|
BNB_SONOS_SEED_HOST: 192.168.1.121
|
||||||
@@ -146,6 +162,9 @@ BNB_PORT | 4534 | Default http port for bonob to listen on
|
|||||||
BNB_URL | http://$(hostname):4534 | URL (including path) for bonob so that sonos devices can communicate. **This must be either the public IP or DNS entry of the bonob instance so that the sonos devices can communicate with it.**
|
BNB_URL | http://$(hostname):4534 | URL (including path) for bonob so that sonos devices can communicate. **This must be either the public IP or DNS entry of the bonob instance so that the sonos devices can communicate with it.**
|
||||||
BNB_SECRET | bonob | secret used for encrypting credentials
|
BNB_SECRET | bonob | secret used for encrypting credentials
|
||||||
BNB_AUTH_TIMEOUT | 1h | Timeout for the sonos auth token, described in the format [ms](https://github.com/vercel/ms), ie. '5s' == 5 seconds, '11h' == 11 hours. In the case of using Navidrome this should be less than the value for ND_SESSIONTIMEOUT
|
BNB_AUTH_TIMEOUT | 1h | Timeout for the sonos auth token, described in the format [ms](https://github.com/vercel/ms), ie. '5s' == 5 seconds, '11h' == 11 hours. In the case of using Navidrome this should be less than the value for ND_SESSIONTIMEOUT
|
||||||
|
BNB_LOG_LEVEL | info | Log level. One of ['debug', 'info', 'warn', 'error']
|
||||||
|
BNB_DISABLE_PLAYLIST_ART | undefined | Disables playlist art generation, ie. when there are many playlists and art generation takes too long
|
||||||
|
BNB_SERVER_LOG_REQUESTS | false | Whether or not to log http requests
|
||||||
BNB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register on startup
|
BNB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register on startup
|
||||||
BNB_SONOS_DEVICE_DISCOVERY | true | Enable/Disable sonos device discovery entirely. Setting this to 'false' will disable sonos device search, regardless of whether a seed host is specified.
|
BNB_SONOS_DEVICE_DISCOVERY | true | Enable/Disable sonos device discovery entirely. Setting this to 'false' will disable sonos device search, regardless of whether a seed host is specified.
|
||||||
BNB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or ommitted for for auto-discovery
|
BNB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or ommitted for for auto-discovery
|
||||||
@@ -153,7 +172,7 @@ BNB_SONOS_SERVICE_NAME | bonob | service name for sonos
|
|||||||
BNB_SONOS_SERVICE_ID | 246 | service id for sonos
|
BNB_SONOS_SERVICE_ID | 246 | service id for sonos
|
||||||
BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone
|
BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone
|
||||||
BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.
|
BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.
|
||||||
BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images as are sourced externally. ie. Navidrome provides spotify URLs
|
BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images that are sourced externally. ie. Navidrome provides spotify URLs. Remember to provide a volume-mapping for Docker, when enabling this cache.
|
||||||
BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s
|
BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s
|
||||||
BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing
|
BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing
|
||||||
BNB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html)
|
BNB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html)
|
||||||
@@ -188,6 +207,12 @@ Generally speaking you will not need to do this very often. However on occassio
|
|||||||
|
|
||||||
Service should now be registered and everything should work as expected.
|
Service should now be registered and everything should work as expected.
|
||||||
|
|
||||||
|
## Multiple registrations within a single household.
|
||||||
|
|
||||||
|
It's possible to register multiple Subsonic clone users for the bonob service in Sonos.
|
||||||
|
Basically this consist of repeating the Sonos app ["Add a service"](#initialising-service-within-sonos-app) steps for each additional user.
|
||||||
|
Afterwards the Sonos app displays a dropdown underneath the service, allowing to switch between users.
|
||||||
|
|
||||||
## Implementing a different music source other than a subsonic clone
|
## Implementing a different music source other than a subsonic clone
|
||||||
|
|
||||||
- Implement the MusicService/MusicLibrary interface
|
- Implement the MusicService/MusicLibrary interface
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ services:
|
|||||||
BNB_URL: http://192.168.1.111:4534
|
BNB_URL: http://192.168.1.111:4534
|
||||||
BNB_SECRET: changeme
|
BNB_SECRET: changeme
|
||||||
BNB_SONOS_SERVICE_ID: 246
|
BNB_SONOS_SERVICE_ID: 246
|
||||||
BNB_SONOS_AUTO_REGISTER: true
|
BNB_SONOS_AUTO_REGISTER: "true"
|
||||||
BNB_SONOS_DEVICE_DISCOVERY: true
|
BNB_SONOS_DEVICE_DISCOVERY: "true"
|
||||||
# ip address of one of your sonos devices
|
# ip address of one of your sonos devices
|
||||||
BNB_SONOS_SEED_HOST: 192.168.1.121
|
BNB_SONOS_SEED_HOST: 192.168.1.121
|
||||||
BNB_SUBSONIC_URL: http://navidrome:4533
|
BNB_SUBSONIC_URL: http://navidrome:4533
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ module.exports = {
|
|||||||
'<rootDir>/node_modules',
|
'<rootDir>/node_modules',
|
||||||
'<rootDir>/build',
|
'<rootDir>/build',
|
||||||
],
|
],
|
||||||
|
testTimeout: Number.parseInt(process.env["JEST_TIMEOUT"] || "5000")
|
||||||
};
|
};
|
||||||
91
package.json
91
package.json
@@ -6,64 +6,67 @@
|
|||||||
"author": "simojenki <simojenki@users.noreply.github.com>",
|
"author": "simojenki <simojenki@users.noreply.github.com>",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@svrooij/sonos": "^2.4.0",
|
"@svrooij/sonos": "^2.5.0",
|
||||||
"@types/express": "^4.17.13",
|
"@types/express": "^4.17.17",
|
||||||
"@types/fs-extra": "^9.0.13",
|
"@types/fs-extra": "^11.0.1",
|
||||||
"@types/jsonwebtoken": "^8.5.5",
|
"@types/jsonwebtoken": "^9.0.1",
|
||||||
"@types/jws": "^3.2.4",
|
"@types/jws": "^3.2.5",
|
||||||
"@types/morgan": "^1.9.3",
|
"@types/morgan": "^1.9.4",
|
||||||
"@types/node": "^16.7.13",
|
"@types/node": "^16.11.7",
|
||||||
"@types/randomstring": "^1.1.8",
|
"@types/randomstring": "^1.1.8",
|
||||||
"@types/sharp": "^0.28.6",
|
"@types/sharp": "^0.31.1",
|
||||||
"@types/underscore": "^1.11.3",
|
"@types/underscore": "^1.11.4",
|
||||||
"@types/uuid": "^8.3.1",
|
"@types/uuid": "^9.0.1",
|
||||||
"axios": "^0.21.4",
|
"@types/xmldom": "0.1.31",
|
||||||
"dayjs": "^1.10.6",
|
"axios": "^1.3.4",
|
||||||
"eta": "^1.12.3",
|
"dayjs": "^1.11.7",
|
||||||
"express": "^4.17.1",
|
"eta": "^2.0.1",
|
||||||
"fp-ts": "^2.11.1",
|
"express": "^4.18.2",
|
||||||
"fs-extra": "^10.0.0",
|
"fp-ts": "^2.13.1",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"fs-extra": "^11.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
"jws": "^4.0.0",
|
"jws": "^4.0.0",
|
||||||
"libxmljs2": "^0.28.0",
|
"libxmljs2": "^0.31.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"node-html-parser": "^4.1.4",
|
"node-html-parser": "^6.1.5",
|
||||||
"randomstring": "^1.2.1",
|
"randomstring": "^1.2.3",
|
||||||
"sharp": "^0.29.1",
|
"sharp": "^0.31.3",
|
||||||
"soap": "^0.42.0",
|
"soap": "^1.0.0",
|
||||||
"ts-md5": "^1.2.9",
|
"ts-md5": "^1.3.1",
|
||||||
"typescript": "^4.4.2",
|
"typescript": "^4.9.5",
|
||||||
"underscore": "^1.13.1",
|
"underscore": "^1.13.6",
|
||||||
"urn-lib": "^2.0.0",
|
"urn-lib": "^2.0.0",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^9.0.0",
|
||||||
"winston": "^3.3.3"
|
"winston": "^3.8.2",
|
||||||
|
"xmldom-ts": "^0.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chai": "^4.2.21",
|
"@types/chai": "^4.3.4",
|
||||||
"@types/jest": "^27.0.1",
|
"@types/jest": "^29.4.0",
|
||||||
"@types/mocha": "^9.0.0",
|
"@types/mocha": "^10.0.1",
|
||||||
"@types/supertest": "^2.0.11",
|
"@types/supertest": "^2.0.12",
|
||||||
"@types/tmp": "^0.2.1",
|
"@types/tmp": "^0.2.3",
|
||||||
"chai": "^4.3.4",
|
"chai": "^4.3.7",
|
||||||
"get-port": "^5.1.1",
|
"get-port": "^6.1.2",
|
||||||
"image-js": "^0.33.0",
|
"image-js": "^0.35.3",
|
||||||
"jest": "^27.1.0",
|
"jest": "^29.4.3",
|
||||||
"nodemon": "^2.0.12",
|
"nodemon": "^2.0.21",
|
||||||
"supertest": "^6.1.6",
|
"supertest": "^6.3.3",
|
||||||
"tmp": "^0.2.1",
|
"tmp": "^0.2.1",
|
||||||
"ts-jest": "^27.0.5",
|
"ts-jest": "^29.0.5",
|
||||||
"ts-mockito": "^2.6.1",
|
"ts-mockito": "^2.6.1",
|
||||||
"ts-node": "^10.2.1",
|
"ts-node": "^10.9.1",
|
||||||
"xmldom-ts": "^0.3.1",
|
"xmldom-ts": "^0.3.1",
|
||||||
"xpath-ts": "^1.3.13"
|
"xpath-ts": "^1.3.13"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rm -Rf build node_modules",
|
"clean": "rm -Rf build node_modules",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts",
|
"dev": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
|
||||||
"devr": "BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts",
|
"devr": "BNB_DISABLE_PLAYLIST_ART=true BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey 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://$(hostname):4534",
|
"register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"gitinfo": "git describe --tags > .gitinfo"
|
"gitinfo": "git describe --tags > .gitinfo"
|
||||||
}
|
},
|
||||||
|
"packageManager": "yarn@1.22.19"
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/app.ts
19
src/app.ts
@@ -5,6 +5,8 @@ import logger from "./logger";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
appendMimeTypeToClientFor,
|
appendMimeTypeToClientFor,
|
||||||
|
axiosImageFetcher,
|
||||||
|
cachingImageFetcher,
|
||||||
DEFAULT,
|
DEFAULT,
|
||||||
Subsonic,
|
Subsonic,
|
||||||
} from "./subsonic";
|
} from "./subsonic";
|
||||||
@@ -15,7 +17,6 @@ import sonos, { bonobService } from "./sonos";
|
|||||||
import { MusicService } from "./music_service";
|
import { MusicService } from "./music_service";
|
||||||
import { SystemClock } from "./clock";
|
import { SystemClock } from "./clock";
|
||||||
import { JWTSmapiLoginTokens } from "./smapi_auth";
|
import { JWTSmapiLoginTokens } from "./smapi_auth";
|
||||||
import { axiosImageFetcher, cachingImageFetcher } from "./images";
|
|
||||||
|
|
||||||
const config = readConfig();
|
const config = readConfig();
|
||||||
const clock = SystemClock;
|
const clock = SystemClock;
|
||||||
@@ -31,7 +32,6 @@ const bonob = bonobService(
|
|||||||
|
|
||||||
const sonosSystem = sonos(config.sonos.discovery);
|
const sonosSystem = sonos(config.sonos.discovery);
|
||||||
|
|
||||||
// todo: just pass in the customClientsForStringArray into subsonic and make it sort it out.
|
|
||||||
const streamUserAgent = config.subsonic.customClientsFor
|
const streamUserAgent = config.subsonic.customClientsFor
|
||||||
? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(","))
|
? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(","))
|
||||||
: DEFAULT;
|
: DEFAULT;
|
||||||
@@ -88,14 +88,14 @@ const app = server(
|
|||||||
clock,
|
clock,
|
||||||
iconColors: config.icons,
|
iconColors: config.icons,
|
||||||
applyContextPath: true,
|
applyContextPath: true,
|
||||||
logRequests: true,
|
logRequests: config.logRequests,
|
||||||
version,
|
version,
|
||||||
smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout),
|
smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout),
|
||||||
externalImageResolver: artistImageFetcher
|
externalImageResolver: artistImageFetcher
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
const expressServer = app.listen(config.port, () => {
|
||||||
logger.info(`Listening on ${config.port} available @ ${config.bonobUrl}`);
|
logger.info(`Listening on ${config.port} available @ ${config.bonobUrl}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,6 +113,15 @@ if (config.sonos.autoRegister) {
|
|||||||
logger.info(`Found device ${d.name}(${d.group}) @ ${d.ip}:${d.port}`);
|
logger.info(`Found device ${d.name}(${d.group}) @ ${d.ip}:${d.port}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
logger.info('SIGTERM signal received: closing HTTP server');
|
||||||
|
expressServer.close(() => {
|
||||||
|
logger.info('HTTP server closed');
|
||||||
|
});
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export default function () {
|
|||||||
validationPattern: COLOR,
|
validationPattern: COLOR,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
logRequests: bnbEnvVar<boolean>("SERVER_LOG_REQUESTS", { default: false, parser: asBoolean }),
|
||||||
sonos: {
|
sonos: {
|
||||||
serviceName: bnbEnvVar<string>("SONOS_SERVICE_NAME", { default: "bonob" })!,
|
serviceName: bnbEnvVar<string>("SONOS_SERVICE_NAME", { default: "bonob" })!,
|
||||||
discovery: {
|
discovery: {
|
||||||
|
|||||||
67
src/http.ts
67
src/http.ts
@@ -1,67 +0,0 @@
|
|||||||
import {
|
|
||||||
AxiosPromise,
|
|
||||||
AxiosRequestConfig,
|
|
||||||
Method,
|
|
||||||
ResponseType,
|
|
||||||
} from "axios";
|
|
||||||
|
|
||||||
// todo: do i need this anymore?
|
|
||||||
export interface Http {
|
|
||||||
(config: AxiosRequestConfig): AxiosPromise<any>;
|
|
||||||
}
|
|
||||||
export interface Http2 extends Http {
|
|
||||||
with: (params: Partial<RequestParams>) => Http2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RequestParams = {
|
|
||||||
baseURL: string;
|
|
||||||
url: string;
|
|
||||||
params: any;
|
|
||||||
headers: any;
|
|
||||||
responseType: ResponseType;
|
|
||||||
method: Method;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrap = (http2: Http2, params: Partial<RequestParams>): Http2 => {
|
|
||||||
const f = ((config: AxiosRequestConfig) => http2(merge(params, config))) as Http2;
|
|
||||||
f.with = (params: Partial<RequestParams>) => wrap(f, params);
|
|
||||||
return f;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const http2From = (http: Http): Http2 => {
|
|
||||||
const f = ((config: AxiosRequestConfig) => http(config)) as Http2;
|
|
||||||
f.with = (defaults: Partial<RequestParams>) => wrap(f, defaults);
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
|
|
||||||
const merge = (
|
|
||||||
defaults: Partial<RequestParams>,
|
|
||||||
config: AxiosRequestConfig
|
|
||||||
) => {
|
|
||||||
let toApply = {
|
|
||||||
...defaults,
|
|
||||||
...config,
|
|
||||||
};
|
|
||||||
if (defaults.params) {
|
|
||||||
toApply = {
|
|
||||||
...toApply,
|
|
||||||
params: {
|
|
||||||
...defaults.params,
|
|
||||||
...config.params,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (defaults.headers) {
|
|
||||||
toApply = {
|
|
||||||
...toApply,
|
|
||||||
headers: {
|
|
||||||
...defaults.headers,
|
|
||||||
...config.headers,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return toApply;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const http =
|
|
||||||
(base: Http, defaults: Partial<RequestParams>): Http => (config: AxiosRequestConfig) => base(merge(defaults, config));
|
|
||||||
84
src/i8n.ts
84
src/i8n.ts
@@ -4,7 +4,7 @@ import { option as O } from "fp-ts";
|
|||||||
import _ from "underscore";
|
import _ from "underscore";
|
||||||
|
|
||||||
export type LANG = "en-US" | "da-DK" | "de-DE" | "es-ES" | "fr-FR" | "it-IT" | "ja-JP" | "nb-NO" | "nl-NL" | "pt-BR" | "sv-SE" | "zh-CN"
|
export type LANG = "en-US" | "da-DK" | "de-DE" | "es-ES" | "fr-FR" | "it-IT" | "ja-JP" | "nb-NO" | "nl-NL" | "pt-BR" | "sv-SE" | "zh-CN"
|
||||||
export type SUPPORTED_LANG = "en-US" | "nl-NL";
|
export type SUPPORTED_LANG = "en-US" | "da-DK" | "fr-FR" | "nl-NL";
|
||||||
export type KEY =
|
export type KEY =
|
||||||
| "AppLinkMessage"
|
| "AppLinkMessage"
|
||||||
| "artists"
|
| "artists"
|
||||||
@@ -88,6 +88,88 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
LOVE: "Love",
|
LOVE: "Love",
|
||||||
LOVE_SUCCESS: "Track loved"
|
LOVE_SUCCESS: "Track loved"
|
||||||
},
|
},
|
||||||
|
"da-DK": {
|
||||||
|
AppLinkMessage: "Forbinder Sonos med $BNB_SONOS_SERVICE_NAME",
|
||||||
|
artists: "Kunstnere",
|
||||||
|
albums: "Album",
|
||||||
|
tracks: "Numre",
|
||||||
|
playlists: "Afspilningslister",
|
||||||
|
genres: "Genre",
|
||||||
|
random: "Tilfældig",
|
||||||
|
topRated: "Højst vurderet",
|
||||||
|
recentlyAdded: "Senest tilføjet",
|
||||||
|
recentlyPlayed: "Senest afspillet",
|
||||||
|
mostPlayed: "Flest afspilninger",
|
||||||
|
success: "Succes",
|
||||||
|
failure: "Fejl",
|
||||||
|
expectedConfig: "Forventet konfiguration",
|
||||||
|
existingServiceConfig: "Eksisterende tjeneste konfiguration",
|
||||||
|
noExistingServiceRegistration: "Ingen eksisterende tjeneste registrering",
|
||||||
|
register: "Registrer",
|
||||||
|
removeRegistration: "Fjern registrering",
|
||||||
|
devices: "Enheder",
|
||||||
|
services: "Tjenester",
|
||||||
|
login: "Log på",
|
||||||
|
logInToBonob: "Log på $BNB_SONOS_SERVICE_NAME",
|
||||||
|
username: "Brugernavn",
|
||||||
|
password: "Adgangskode",
|
||||||
|
successfullyRegistered: "Registreret med succes",
|
||||||
|
registrationFailed: "Registrering fejlede!",
|
||||||
|
successfullyRemovedRegistration: "Registrering fjernet med succes",
|
||||||
|
failedToRemoveRegistration: "FJernelse af registrering fejlede!",
|
||||||
|
invalidLinkCode: "Ugyldig linkCode!",
|
||||||
|
loginSuccessful: "Log på succes!",
|
||||||
|
loginFailed: "Log på fejlede!",
|
||||||
|
noSonosDevices: "Ingen Sonos enheder",
|
||||||
|
favourites: "Favoritter",
|
||||||
|
STAR: "Tilføj stjerne",
|
||||||
|
UNSTAR: "Fjern stjerne",
|
||||||
|
STAR_SUCCESS: "Stjerne tilføjet",
|
||||||
|
UNSTAR_SUCCESS: "Stjerne fjernet",
|
||||||
|
LOVE: "Synes godt om",
|
||||||
|
LOVE_SUCCESS: "Syntes godt om"
|
||||||
|
},
|
||||||
|
"fr-FR": {
|
||||||
|
AppLinkMessage: "Associer Sonos à $BNB_SONOS_SERVICE_NAME",
|
||||||
|
artists: "Artistes",
|
||||||
|
albums: "Albums",
|
||||||
|
tracks: "Pistes",
|
||||||
|
playlists: "Playlists",
|
||||||
|
genres: "Genres",
|
||||||
|
random: "Aléatoire",
|
||||||
|
topRated: "Les mieux notés",
|
||||||
|
recentlyAdded: "Récemment ajouté",
|
||||||
|
recentlyPlayed: "Récemment joué",
|
||||||
|
mostPlayed: "Les plus joué",
|
||||||
|
success: "Succès",
|
||||||
|
failure: "Échec",
|
||||||
|
expectedConfig: "Configuration attendue",
|
||||||
|
existingServiceConfig: "La configuration de service existe",
|
||||||
|
noExistingServiceRegistration: "Aucun enregistrement de service existant",
|
||||||
|
register: "Inscription",
|
||||||
|
removeRegistration: "Supprimer l'inscription",
|
||||||
|
devices: "Appareils",
|
||||||
|
services: "Services",
|
||||||
|
login: "Se connecter",
|
||||||
|
logInToBonob: "Se connecter à $BNB_SONOS_SERVICE_NAME",
|
||||||
|
username: "Nom d'utilisateur",
|
||||||
|
password: "Mot de passe",
|
||||||
|
successfullyRegistered: "Connecté avec succès",
|
||||||
|
registrationFailed: "Échec de la connexion !",
|
||||||
|
successfullyRemovedRegistration: "Inscription supprimée avec succès",
|
||||||
|
failedToRemoveRegistration: "Échec de la suppression de l'inscription !",
|
||||||
|
invalidLinkCode: "Code non valide !",
|
||||||
|
loginSuccessful: "Connexion réussie !",
|
||||||
|
loginFailed: "La connexion a échoué !",
|
||||||
|
noSonosDevices: "Aucun appareil Sonos",
|
||||||
|
favourites: "Favoris",
|
||||||
|
STAR: "Suivre",
|
||||||
|
UNSTAR: "Ne plus suivre",
|
||||||
|
STAR_SUCCESS: "Piste suivie",
|
||||||
|
UNSTAR_SUCCESS: "Piste non suivie",
|
||||||
|
LOVE: "Aimer",
|
||||||
|
LOVE_SUCCESS: "Pistes aimée"
|
||||||
|
},
|
||||||
"nl-NL": {
|
"nl-NL": {
|
||||||
AppLinkMessage: "Sonos koppelen aan $BNB_SONOS_SERVICE_NAME",
|
AppLinkMessage: "Sonos koppelen aan $BNB_SONOS_SERVICE_NAME",
|
||||||
artists: "Artiesten",
|
artists: "Artiesten",
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
|
|
||||||
import sharp from "sharp";
|
|
||||||
import fse from "fs-extra";
|
|
||||||
import path from "path";
|
|
||||||
import { Md5 } from "ts-md5/dist/md5";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
import { CoverArt } from "./music_service";
|
|
||||||
import { BROWSER_HEADERS } from "./utils";
|
|
||||||
|
|
||||||
export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>;
|
|
||||||
|
|
||||||
export const cachingImageFetcher =
|
|
||||||
(cacheDir: string, delegate: ImageFetcher) =>
|
|
||||||
async (url: string): Promise<CoverArt | undefined> => {
|
|
||||||
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
|
|
||||||
return fse
|
|
||||||
.readFile(filename)
|
|
||||||
.then((data) => ({ contentType: "image/png", data }))
|
|
||||||
.catch(() =>
|
|
||||||
delegate(url).then((image) => {
|
|
||||||
if (image) {
|
|
||||||
return sharp(image.data)
|
|
||||||
.png()
|
|
||||||
.toBuffer()
|
|
||||||
.then((png) => {
|
|
||||||
return fse
|
|
||||||
.writeFile(filename, png)
|
|
||||||
.then(() => ({ contentType: "image/png", data: png }));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const axiosImageFetcher = (url: string): Promise<CoverArt | undefined> =>
|
|
||||||
axios
|
|
||||||
.get(url, {
|
|
||||||
headers: BROWSER_HEADERS,
|
|
||||||
responseType: "arraybuffer",
|
|
||||||
})
|
|
||||||
.then((res) => ({
|
|
||||||
contentType: res.headers["content-type"],
|
|
||||||
data: Buffer.from(res.data, "binary"),
|
|
||||||
}))
|
|
||||||
.catch(() => undefined);
|
|
||||||
@@ -6,7 +6,7 @@ export function debugIt<T>(thing: T): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logger = createLogger({
|
const logger = createLogger({
|
||||||
level: 'debug',
|
level: process.env["BNB_LOG_LEVEL"] || 'info',
|
||||||
format: format.combine(
|
format: format.combine(
|
||||||
format.timestamp({
|
format.timestamp({
|
||||||
format: 'YYYY-MM-DD HH:mm:ss'
|
format: 'YYYY-MM-DD HH:mm:ss'
|
||||||
|
|||||||
@@ -15,13 +15,7 @@ export class AuthFailure extends Error {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IdName = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ArtistSummary = {
|
export type ArtistSummary = {
|
||||||
// todo: why can this be undefined?
|
|
||||||
id: string | undefined;
|
id: string | undefined;
|
||||||
name: string;
|
name: string;
|
||||||
image: BUrn | undefined;
|
image: BUrn | undefined;
|
||||||
@@ -71,8 +65,8 @@ export type Track = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Paging = {
|
export type Paging = {
|
||||||
_index: number | undefined;
|
_index: number;
|
||||||
_count: number | undefined;
|
_count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Result<T> = {
|
export type Result<T> = {
|
||||||
@@ -80,10 +74,9 @@ export type Result<T> = {
|
|||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function slice2<T>({ _index, _count }: Partial<Paging> = {}) {
|
export function slice2<T>({ _index, _count }: Paging) {
|
||||||
const i = _index || 0;
|
|
||||||
return (things: T[]): [T[], number] => [
|
return (things: T[]): [T[], number] => [
|
||||||
_count ? things.slice(i, i + _count) : things.slice(i),
|
things.slice(_index, _index + _count),
|
||||||
things.length,
|
things.length,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -145,10 +138,6 @@ export type Playlist = PlaylistSummary & {
|
|||||||
entries: Track[]
|
entries: Track[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Sortable = {
|
|
||||||
sortName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const range = (size: number) => [...Array(size).keys()];
|
export const range = (size: number) => [...Array(size).keys()];
|
||||||
|
|
||||||
export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
|
export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] =>
|
||||||
@@ -163,7 +152,7 @@ export interface MusicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MusicLibrary {
|
export interface MusicLibrary {
|
||||||
artists(q: ArtistQuery): Promise<Result<ArtistSummary & Sortable>>;
|
artists(q: ArtistQuery): Promise<Result<ArtistSummary>>;
|
||||||
artist(id: string): Promise<Artist>;
|
artist(id: string): Promise<Artist>;
|
||||||
albums(q: AlbumQuery): Promise<Result<AlbumSummary>>;
|
albums(q: AlbumQuery): Promise<Result<AlbumSummary>>;
|
||||||
album(id: string): Promise<Album>;
|
album(id: string): Promise<Album>;
|
||||||
|
|||||||
@@ -33,10 +33,13 @@ import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n";
|
|||||||
import { Icon, ICONS, festivals, features } from "./icon";
|
import { Icon, ICONS, festivals, features } from "./icon";
|
||||||
import _, { shuffle } from "underscore";
|
import _, { shuffle } from "underscore";
|
||||||
import morgan from "morgan";
|
import morgan from "morgan";
|
||||||
import { mask, takeWithRepeats } from "./utils";
|
import { takeWithRepeats } from "./utils";
|
||||||
import { parse } from "./burn";
|
import { parse } from "./burn";
|
||||||
import { axiosImageFetcher, ImageFetcher } from "./images";
|
import { axiosImageFetcher, ImageFetcher } from "./subsonic";
|
||||||
import { JWTSmapiLoginTokens, SmapiAuthTokens } from "./smapi_auth";
|
import {
|
||||||
|
JWTSmapiLoginTokens,
|
||||||
|
SmapiAuthTokens,
|
||||||
|
} from "./smapi_auth";
|
||||||
|
|
||||||
export const BONOB_ACCESS_TOKEN_HEADER = "bat";
|
export const BONOB_ACCESS_TOKEN_HEADER = "bat";
|
||||||
|
|
||||||
@@ -371,31 +374,26 @@ function server(
|
|||||||
const id = req.params["id"]!;
|
const id = req.params["id"]!;
|
||||||
const trace = uuid();
|
const trace = uuid();
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
`${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify(
|
`${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify(
|
||||||
req.query
|
req.query
|
||||||
)}, headers=${JSON.stringify(mask(req.headers, ["bnbt", "bnbk"]))}`
|
)}, headers=${JSON.stringify({ ...req.headers, "bnbt": "*****", "bnbk": "*****" })}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const serviceToken = pipe(
|
const serviceToken = pipe(
|
||||||
E.fromNullable("Missing bnbt header")(req.headers["bnbt"] as string),
|
E.fromNullable("Missing bnbt header")(req.headers["bnbt"] as string),
|
||||||
E.chain((token) =>
|
E.chain(token => pipe(
|
||||||
pipe(
|
|
||||||
E.fromNullable("Missing bnbk header")(req.headers["bnbk"] as string),
|
E.fromNullable("Missing bnbk header")(req.headers["bnbk"] as string),
|
||||||
E.map((key) => ({ token, key }))
|
E.map(key => ({ token, key }))
|
||||||
)
|
)),
|
||||||
),
|
|
||||||
E.chain((auth) =>
|
E.chain((auth) =>
|
||||||
pipe(
|
pipe(
|
||||||
smapiAuthTokens.verify(auth),
|
smapiAuthTokens.verify(auth),
|
||||||
E.mapLeft((_) => "Auth token failed to verify")
|
E.mapLeft((_) => "Auth token failed to verify")
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
E.getOrElseW((e: string) => {
|
E.getOrElseW(() => undefined)
|
||||||
logger.error(`Failed to get serviceToken for stream: ${e}`);
|
)
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!serviceToken) {
|
if (!serviceToken) {
|
||||||
return res.status(401).send();
|
return res.status(401).send();
|
||||||
@@ -411,7 +409,7 @@ function server(
|
|||||||
.then((stream) => ({ musicLibrary: it, stream }))
|
.then((stream) => ({ musicLibrary: it, stream }))
|
||||||
)
|
)
|
||||||
.then(({ musicLibrary, stream }) => {
|
.then(({ musicLibrary, stream }) => {
|
||||||
logger.info(
|
logger.debug(
|
||||||
`${trace} bnb<- stream response from music service for ${id}, status=${
|
`${trace} bnb<- stream response from music service for ${id}, status=${
|
||||||
stream.status
|
stream.status
|
||||||
}, headers=(${JSON.stringify(stream.headers)})`
|
}, headers=(${JSON.stringify(stream.headers)})`
|
||||||
@@ -437,7 +435,7 @@ function server(
|
|||||||
sendStream: boolean;
|
sendStream: boolean;
|
||||||
nowPlaying: boolean;
|
nowPlaying: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
logger.info(
|
logger.debug(
|
||||||
`${trace} bnb-> ${
|
`${trace} bnb-> ${
|
||||||
req.path
|
req.path
|
||||||
}, status=${status}, headers=${JSON.stringify(headers)}`
|
}, status=${status}, headers=${JSON.stringify(headers)}`
|
||||||
|
|||||||
82
src/smapi.ts
82
src/smapi.ts
@@ -19,7 +19,6 @@ import {
|
|||||||
Playlist,
|
Playlist,
|
||||||
Rating,
|
Rating,
|
||||||
slice2,
|
slice2,
|
||||||
Sortable,
|
|
||||||
Track,
|
Track,
|
||||||
} from "./music_service";
|
} from "./music_service";
|
||||||
import { APITokens } from "./api_tokens";
|
import { APITokens } from "./api_tokens";
|
||||||
@@ -267,6 +266,9 @@ export const playlistAlbumArtURL = (
|
|||||||
bonobUrl: URLBuilder,
|
bonobUrl: URLBuilder,
|
||||||
playlist: Playlist
|
playlist: Playlist
|
||||||
) => {
|
) => {
|
||||||
|
// todo: this should be put into config, or even just removed for the ND music source
|
||||||
|
if(process.env["BNB_DISABLE_PLAYLIST_ART"]) return iconArtURI(bonobUrl, "music");
|
||||||
|
|
||||||
const burns: BUrn[] = uniq(
|
const burns: BUrn[] = uniq(
|
||||||
playlist.entries.filter((it) => it.coverArt != undefined),
|
playlist.entries.filter((it) => it.coverArt != undefined),
|
||||||
(it) => it.album.id
|
(it) => it.album.id
|
||||||
@@ -367,54 +369,6 @@ export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({
|
|||||||
albumArtURI: defaultArtistArtURI(bonobUrl, artist).href(),
|
albumArtURI: defaultArtistArtURI(bonobUrl, artist).href(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const scrollIndicesFrom = (things: Sortable[]) => {
|
|
||||||
const indicies: Record<string, number | undefined> = {
|
|
||||||
"A":undefined,
|
|
||||||
"B":undefined,
|
|
||||||
"C":undefined,
|
|
||||||
"D":undefined,
|
|
||||||
"E":undefined,
|
|
||||||
"F":undefined,
|
|
||||||
"G":undefined,
|
|
||||||
"H":undefined,
|
|
||||||
"I":undefined,
|
|
||||||
"J":undefined,
|
|
||||||
"K":undefined,
|
|
||||||
"L":undefined,
|
|
||||||
"M":undefined,
|
|
||||||
"N":undefined,
|
|
||||||
"O":undefined,
|
|
||||||
"P":undefined,
|
|
||||||
"Q":undefined,
|
|
||||||
"R":undefined,
|
|
||||||
"S":undefined,
|
|
||||||
"T":undefined,
|
|
||||||
"U":undefined,
|
|
||||||
"V":undefined,
|
|
||||||
"W":undefined,
|
|
||||||
"X":undefined,
|
|
||||||
"Y":undefined,
|
|
||||||
"Z":undefined,
|
|
||||||
}
|
|
||||||
const upperNames = things.map(thing => thing.sortName.toUpperCase());
|
|
||||||
for(var i = 0; i < upperNames.length; i++) {
|
|
||||||
const char = upperNames[i]![0]!;
|
|
||||||
if(Object.keys(indicies).includes(char) && indicies[char] == undefined) {
|
|
||||||
indicies[char] = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var lastIndex = 0;
|
|
||||||
const result: string[] = [];
|
|
||||||
Object.entries(indicies).forEach(([letter, index]) => {
|
|
||||||
result.push(letter);
|
|
||||||
if(index) {
|
|
||||||
lastIndex = index;
|
|
||||||
}
|
|
||||||
result.push(`${lastIndex}`);
|
|
||||||
})
|
|
||||||
return result.join(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitId<T>(id: string) {
|
function splitId<T>(id: string) {
|
||||||
const [type, typeId] = id.split(":");
|
const [type, typeId] = id.split(":");
|
||||||
return (t: T) => ({
|
return (t: T) => ({
|
||||||
@@ -756,7 +710,6 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
title: lang("artists"),
|
title: lang("artists"),
|
||||||
albumArtURI: iconArtURI(bonobUrl, "artists").href(),
|
albumArtURI: iconArtURI(bonobUrl, "artists").href(),
|
||||||
itemType: "container",
|
itemType: "container",
|
||||||
canScroll: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "albums",
|
id: "albums",
|
||||||
@@ -918,8 +871,13 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
.playlists()
|
.playlists()
|
||||||
.then((it) =>
|
.then((it) =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
it.map((playlist) =>
|
it.map((playlist) => {
|
||||||
musicLibrary.playlist(playlist.id)
|
return {
|
||||||
|
id: playlist.id,
|
||||||
|
name: playlist.name,
|
||||||
|
entries: []
|
||||||
|
};
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -995,23 +953,6 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
throw `Unsupported getMetadata id=${id}`;
|
throw `Unsupported getMetadata id=${id}`;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
getScrollIndices: async (
|
|
||||||
{ id }: { id: string },
|
|
||||||
_,
|
|
||||||
soapyHeaders: SoapyHeaders
|
|
||||||
) => {
|
|
||||||
switch(id) {
|
|
||||||
case "artists": {
|
|
||||||
return login(soapyHeaders?.credentials)
|
|
||||||
.then(({ musicLibrary }) => musicLibrary.artists({ _index: 0, _count: undefined }))
|
|
||||||
.then((artists) => ({
|
|
||||||
getScrollIndicesResult: scrollIndicesFrom(artists.results)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
throw `Unsupported getScrollIndices id=${id}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
createContainer: async (
|
createContainer: async (
|
||||||
{ title, seedId }: { title: string; seedId: string | undefined },
|
{ title, seedId }: { title: string; seedId: string | undefined },
|
||||||
_,
|
_,
|
||||||
@@ -1133,8 +1074,9 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
|
|
||||||
soapyService.log = (type, data) => {
|
soapyService.log = (type, data) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
// routing all soap info messages to debug so less noisy
|
||||||
case "info":
|
case "info":
|
||||||
logger.info({ level: "info", data });
|
logger.debug({ level: "info", data });
|
||||||
break;
|
break;
|
||||||
case "warn":
|
case "warn":
|
||||||
logger.warn({ level: "warn", data });
|
logger.warn({ level: "warn", data });
|
||||||
|
|||||||
978
src/subsonic.ts
Normal file
978
src/subsonic.ts
Normal file
@@ -0,0 +1,978 @@
|
|||||||
|
import { option as O, taskEither as TE } from "fp-ts";
|
||||||
|
import * as A from "fp-ts/Array";
|
||||||
|
import { ordString } from "fp-ts/lib/Ord";
|
||||||
|
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,
|
||||||
|
} from "./music_service";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import _ from "underscore";
|
||||||
|
import fse from "fs-extra";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export const BROWSER_HEADERS = {
|
||||||
|
accept:
|
||||||
|
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||||
|
"accept-encoding": "gzip, deflate, br",
|
||||||
|
"accept-language": "en-GB,en;q=0.5",
|
||||||
|
"upgrade-insecure-requests": "1",
|
||||||
|
"user-agent":
|
||||||
|
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const t = (password: string, s: string) =>
|
||||||
|
Md5.hashStr(`${password}${s}`);
|
||||||
|
|
||||||
|
export const t_and_s = (password: string) => {
|
||||||
|
const s = randomstring.generate();
|
||||||
|
return {
|
||||||
|
t: t(password, s),
|
||||||
|
s,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png";
|
||||||
|
|
||||||
|
export const isValidImage = (url: string | undefined) =>
|
||||||
|
url != undefined && !url.endsWith(DODGY_IMAGE_NAME);
|
||||||
|
|
||||||
|
type SubsonicEnvelope = {
|
||||||
|
"subsonic-response": SubsonicResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubsonicResponse = {
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type album = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string | undefined;
|
||||||
|
artistId: string | undefined;
|
||||||
|
coverArt: string | undefined;
|
||||||
|
genre: string | undefined;
|
||||||
|
year: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type artist = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
albumCount: number;
|
||||||
|
artistImageUrl: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetArtistsResponse = SubsonicResponse & {
|
||||||
|
artists: {
|
||||||
|
index: {
|
||||||
|
artist: artist[];
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetAlbumListResponse = SubsonicResponse & {
|
||||||
|
albumList2: {
|
||||||
|
album: album[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type genre = {
|
||||||
|
songCount: number;
|
||||||
|
albumCount: number;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetGenresResponse = SubsonicResponse & {
|
||||||
|
genres: {
|
||||||
|
genre: genre[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubsonicError = SubsonicResponse & {
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type images = {
|
||||||
|
smallImageUrl: string | undefined;
|
||||||
|
mediumImageUrl: string | undefined;
|
||||||
|
largeImageUrl: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type artistInfo = images & {
|
||||||
|
biography: string | undefined;
|
||||||
|
musicBrainzId: string | undefined;
|
||||||
|
lastFmUrl: string | undefined;
|
||||||
|
similarArtist: artist[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ArtistSummary = IdName & {
|
||||||
|
image: BUrn | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetArtistInfoResponse = SubsonicResponse & {
|
||||||
|
artistInfo2: artistInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetArtistResponse = SubsonicResponse & {
|
||||||
|
artist: artist & {
|
||||||
|
album: album[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type song = {
|
||||||
|
id: string;
|
||||||
|
parent: string | undefined;
|
||||||
|
title: string;
|
||||||
|
album: string | undefined;
|
||||||
|
albumId: string | undefined;
|
||||||
|
artist: string | undefined;
|
||||||
|
artistId: string | undefined;
|
||||||
|
track: number | undefined;
|
||||||
|
year: string | undefined;
|
||||||
|
genre: string | undefined;
|
||||||
|
coverArt: string | undefined;
|
||||||
|
created: string | undefined;
|
||||||
|
duration: number | undefined;
|
||||||
|
bitRate: number | undefined;
|
||||||
|
suffix: string | undefined;
|
||||||
|
contentType: string | undefined;
|
||||||
|
type: string | undefined;
|
||||||
|
userRating: number | undefined;
|
||||||
|
starred: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetAlbumResponse = {
|
||||||
|
album: album & {
|
||||||
|
song: song[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type playlist = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetPlaylistResponse = {
|
||||||
|
playlist: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
entry: song[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetPlaylistsResponse = {
|
||||||
|
playlists: { playlist: playlist[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetSimilarSongsResponse = {
|
||||||
|
similarSongs2: { song: song[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetTopSongsResponse = {
|
||||||
|
topSongs: { song: song[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetSongResponse = {
|
||||||
|
song: song;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetStarredResponse = {
|
||||||
|
starred2: {
|
||||||
|
song: song[];
|
||||||
|
album: album[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PingResponse = {
|
||||||
|
status: string;
|
||||||
|
version: string;
|
||||||
|
type: string;
|
||||||
|
serverVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Search3Response = SubsonicResponse & {
|
||||||
|
searchResult3: {
|
||||||
|
artist: artist[];
|
||||||
|
album: album[];
|
||||||
|
song: song[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isError(
|
||||||
|
subsonicResponse: SubsonicResponse
|
||||||
|
): subsonicResponse is SubsonicError {
|
||||||
|
return (subsonicResponse as SubsonicError).error !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdName = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
|
||||||
|
pipe(
|
||||||
|
coverArt,
|
||||||
|
O.fromNullable,
|
||||||
|
O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })),
|
||||||
|
O.getOrElseW(() => undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const artistImageURN = (
|
||||||
|
spec: Partial<{
|
||||||
|
artistId: string | undefined;
|
||||||
|
artistImageURL: string | undefined;
|
||||||
|
}>
|
||||||
|
): BUrn | undefined => {
|
||||||
|
const deets = {
|
||||||
|
artistId: undefined,
|
||||||
|
artistImageURL: undefined,
|
||||||
|
...spec,
|
||||||
|
};
|
||||||
|
if (deets.artistImageURL && isValidImage(deets.artistImageURL)) {
|
||||||
|
return {
|
||||||
|
system: "external",
|
||||||
|
resource: deets.artistImageURL,
|
||||||
|
};
|
||||||
|
} else if (artistIsInLibrary(deets.artistId)) {
|
||||||
|
return {
|
||||||
|
system: "subsonic",
|
||||||
|
resource: `art:${deets.artistId!}`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const asTrack = (album: Album, song: song): Track => ({
|
||||||
|
id: song.id,
|
||||||
|
name: song.title,
|
||||||
|
mimeType: song.contentType!,
|
||||||
|
duration: song.duration || 0,
|
||||||
|
number: song.track || 0,
|
||||||
|
genre: maybeAsGenre(song.genre),
|
||||||
|
coverArt: coverArtURN(song.coverArt),
|
||||||
|
album,
|
||||||
|
artist: {
|
||||||
|
id: song.artistId,
|
||||||
|
name: song.artist ? song.artist : "?",
|
||||||
|
image: song.artistId
|
||||||
|
? artistImageURN({ artistId: song.artistId })
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
rating: {
|
||||||
|
love: song.starred != undefined,
|
||||||
|
stars:
|
||||||
|
song.userRating && song.userRating <= 5 && song.userRating >= 0
|
||||||
|
? song.userRating
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const asAlbum = (album: album): Album => ({
|
||||||
|
id: album.id,
|
||||||
|
name: album.name,
|
||||||
|
year: album.year,
|
||||||
|
genre: maybeAsGenre(album.genre),
|
||||||
|
artistId: album.artistId,
|
||||||
|
artistName: album.artist,
|
||||||
|
coverArt: coverArtURN(album.coverArt),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const asGenre = (genreName: string) => ({
|
||||||
|
id: b64Encode(genreName),
|
||||||
|
name: genreName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
||||||
|
pipe(
|
||||||
|
genreName,
|
||||||
|
O.fromNullable,
|
||||||
|
O.map(asGenre),
|
||||||
|
O.getOrElseW(() => undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
export type StreamClientApplication = (track: Track) => string;
|
||||||
|
|
||||||
|
const DEFAULT_CLIENT_APPLICATION = "bonob";
|
||||||
|
const USER_AGENT = "bonob";
|
||||||
|
|
||||||
|
export const DEFAULT: StreamClientApplication = (_: Track) =>
|
||||||
|
DEFAULT_CLIENT_APPLICATION;
|
||||||
|
|
||||||
|
export function appendMimeTypeToClientFor(mimeTypes: string[]) {
|
||||||
|
return (track: Track) =>
|
||||||
|
mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const asURLSearchParams = (q: any) => {
|
||||||
|
const urlSearchParams = new URLSearchParams();
|
||||||
|
Object.keys(q).forEach((k) => {
|
||||||
|
_.flatten([q[k]]).forEach((v) => {
|
||||||
|
urlSearchParams.append(k, `${v}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return urlSearchParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>;
|
||||||
|
|
||||||
|
export const cachingImageFetcher =
|
||||||
|
(cacheDir: string, delegate: ImageFetcher) =>
|
||||||
|
async (url: string): Promise<CoverArt | undefined> => {
|
||||||
|
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
|
||||||
|
return fse
|
||||||
|
.readFile(filename)
|
||||||
|
.then((data) => ({ contentType: "image/png", data }))
|
||||||
|
.catch(() =>
|
||||||
|
delegate(url).then((image) => {
|
||||||
|
if (image) {
|
||||||
|
return sharp(image.data)
|
||||||
|
.png()
|
||||||
|
.toBuffer()
|
||||||
|
.then((png) => {
|
||||||
|
return fse
|
||||||
|
.writeFile(filename, png)
|
||||||
|
.then(() => ({ contentType: "image/png", data: png }));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const axiosImageFetcher = (url: string): Promise<CoverArt | undefined> =>
|
||||||
|
axios
|
||||||
|
.get(url, {
|
||||||
|
headers: BROWSER_HEADERS,
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
})
|
||||||
|
.then((res) => ({
|
||||||
|
contentType: res.headers["content-type"],
|
||||||
|
data: Buffer.from(res.data, "binary"),
|
||||||
|
}))
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
||||||
|
alphabeticalByArtist: "alphabeticalByArtist",
|
||||||
|
alphabeticalByName: "alphabeticalByName",
|
||||||
|
byGenre: "byGenre",
|
||||||
|
random: "random",
|
||||||
|
recentlyPlayed: "recent",
|
||||||
|
mostPlayed: "frequent",
|
||||||
|
recentlyAdded: "newest",
|
||||||
|
favourited: "starred",
|
||||||
|
starred: "highest",
|
||||||
|
};
|
||||||
|
|
||||||
|
const artistIsInLibrary = (artistId: string | undefined) =>
|
||||||
|
artistId != undefined && artistId != "-1";
|
||||||
|
|
||||||
|
type SubsonicCredentials = Credentials & {
|
||||||
|
type: string;
|
||||||
|
bearer: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
url: string;
|
||||||
|
streamClientApplication: StreamClientApplication;
|
||||||
|
externalImageFetcher: ImageFetcher;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
url: string,
|
||||||
|
streamClientApplication: StreamClientApplication = DEFAULT,
|
||||||
|
externalImageFetcher: ImageFetcher = axiosImageFetcher
|
||||||
|
) {
|
||||||
|
this.url = url;
|
||||||
|
this.streamClientApplication = streamClientApplication;
|
||||||
|
this.externalImageFetcher = externalImageFetcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
get = async (
|
||||||
|
{ username, password }: Credentials,
|
||||||
|
path: string,
|
||||||
|
q: {} = {},
|
||||||
|
config: AxiosRequestConfig | undefined = {}
|
||||||
|
) =>
|
||||||
|
axios
|
||||||
|
.get(`${this.url}${path}`, {
|
||||||
|
params: asURLSearchParams({
|
||||||
|
u: username,
|
||||||
|
v: "1.16.1",
|
||||||
|
c: DEFAULT_CLIENT_APPLICATION,
|
||||||
|
...t_and_s(password),
|
||||||
|
...q,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
throw `Subsonic failed with: ${e}`;
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status != 200 && response.status != 206) {
|
||||||
|
throw `Subsonic failed with a ${response.status || "no!"} status`;
|
||||||
|
} else return response;
|
||||||
|
});
|
||||||
|
|
||||||
|
getJSON = async <T>(
|
||||||
|
{ username, password }: Credentials,
|
||||||
|
path: string,
|
||||||
|
q: {} = {}
|
||||||
|
): Promise<T> =>
|
||||||
|
this.get({ username, password }, path, { f: "json", ...q })
|
||||||
|
.then((response) => response.data as SubsonicEnvelope)
|
||||||
|
.then((json) => json["subsonic-response"])
|
||||||
|
.then((json) => {
|
||||||
|
if (isError(json)) throw `Subsonic error:${json.error.message}`;
|
||||||
|
else return json as unknown as T;
|
||||||
|
});
|
||||||
|
|
||||||
|
generateToken = (credentials: Credentials) =>
|
||||||
|
pipe(
|
||||||
|
TE.tryCatch(
|
||||||
|
() =>
|
||||||
|
this.getJSON<PingResponse>(
|
||||||
|
_.pick(credentials, "username", "password"),
|
||||||
|
"/rest/ping.view"
|
||||||
|
),
|
||||||
|
(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
|
||||||
|
): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> =>
|
||||||
|
this.getJSON<GetArtistsResponse>(credentials, "/rest/getArtists")
|
||||||
|
.then((it) => (it.artists.index || []).flatMap((it) => it.artist || []))
|
||||||
|
.then((artists) =>
|
||||||
|
artists.map((artist) => ({
|
||||||
|
id: `${artist.id}`,
|
||||||
|
name: artist.name,
|
||||||
|
albumCount: artist.albumCount,
|
||||||
|
image: artistImageURN({
|
||||||
|
artistId: artist.id,
|
||||||
|
artistImageURL: artist.artistImageUrl,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
getArtistInfo = (
|
||||||
|
credentials: Credentials,
|
||||||
|
id: string
|
||||||
|
): Promise<{
|
||||||
|
similarArtist: (ArtistSummary & { inLibrary: boolean })[];
|
||||||
|
images: {
|
||||||
|
s: string | undefined;
|
||||||
|
m: string | undefined;
|
||||||
|
l: string | undefined;
|
||||||
|
};
|
||||||
|
}> =>
|
||||||
|
this.getJSON<GetArtistInfoResponse>(credentials, "/rest/getArtistInfo2", {
|
||||||
|
id,
|
||||||
|
count: 50,
|
||||||
|
includeNotPresent: true,
|
||||||
|
})
|
||||||
|
.then((it) => it.artistInfo2)
|
||||||
|
.then((it) => ({
|
||||||
|
images: {
|
||||||
|
s: it.smallImageUrl,
|
||||||
|
m: it.mediumImageUrl,
|
||||||
|
l: it.largeImageUrl,
|
||||||
|
},
|
||||||
|
similarArtist: (it.similarArtist || []).map((artist) => ({
|
||||||
|
id: `${artist.id}`,
|
||||||
|
name: artist.name,
|
||||||
|
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) => ({
|
||||||
|
id: album.id,
|
||||||
|
name: album.name,
|
||||||
|
year: album.year,
|
||||||
|
genre: maybeAsGenre(album.genre),
|
||||||
|
artistId: album.artistId,
|
||||||
|
artistName: album.artist,
|
||||||
|
coverArt: coverArtURN(album.coverArt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
getArtist = (
|
||||||
|
credentials: Credentials,
|
||||||
|
id: string
|
||||||
|
): Promise<
|
||||||
|
IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] }
|
||||||
|
> =>
|
||||||
|
this.getJSON<GetArtistResponse>(credentials, "/rest/getArtist", {
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
.then((it) => it.artist)
|
||||||
|
.then((it) => ({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
artistImageUrl: it.artistImageUrl,
|
||||||
|
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" },
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
});
|
||||||
|
|
||||||
|
getTrack = (credentials: Credentials, id: string) =>
|
||||||
|
this.getJSON<GetSongResponse>(credentials, "/rest/getSong", {
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
.then((it) => it.song)
|
||||||
|
.then((song) =>
|
||||||
|
this.getAlbum(credentials, song.albumId!).then((album) =>
|
||||||
|
asTrack(album, song)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
getStarred = (credentials: Credentials) =>
|
||||||
|
this.getJSON<GetStarredResponse>(credentials, "/rest/getStarred2").then(
|
||||||
|
(it) => new Set(it.starred2.song.map((it) => it.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
toAlbumSummary = (albumList: album[]): AlbumSummary[] =>
|
||||||
|
albumList.map((album) => ({
|
||||||
|
id: album.id,
|
||||||
|
name: album.name,
|
||||||
|
year: album.year,
|
||||||
|
genre: maybeAsGenre(album.genre),
|
||||||
|
artistId: album.artistId,
|
||||||
|
artistName: album.artist,
|
||||||
|
coverArt: coverArtURN(album.coverArt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
search3 = (credentials: Credentials, q: any) =>
|
||||||
|
this.getJSON<Search3Response>(credentials, "/rest/search3", {
|
||||||
|
artistCount: 0,
|
||||||
|
albumCount: 0,
|
||||||
|
songCount: 0,
|
||||||
|
...q,
|
||||||
|
}).then((it) => ({
|
||||||
|
artists: it.searchResult3.artist || [],
|
||||||
|
albums: it.searchResult3.album || [],
|
||||||
|
songs: it.searchResult3.song || [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
getAlbumList2 = (credentials: Credentials, q: AlbumQuery) =>
|
||||||
|
Promise.all([
|
||||||
|
this.getArtists(credentials).then((it) =>
|
||||||
|
_.inject(it, (total, artist) => total + artist.albumCount, 0)
|
||||||
|
),
|
||||||
|
this.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
|
||||||
|
type: AlbumQueryTypeToSubsonicType[q.type],
|
||||||
|
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
|
||||||
|
size: 500,
|
||||||
|
offset: q._index,
|
||||||
|
})
|
||||||
|
.then((response) => response.albumList2.album || [])
|
||||||
|
.then(this.toAlbumSummary),
|
||||||
|
]).then(([total, albums]) => ({
|
||||||
|
results: albums.slice(0, q._count),
|
||||||
|
total: albums.length == 500 ? total : q._index + albums.length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// getStarred2 = (credentials: Credentials): Promise<{ albums: Album[] }> =>
|
||||||
|
// this.getJSON<GetStarredResponse>(credentials, "/rest/getStarred2")
|
||||||
|
// .then((it) => it.starred2)
|
||||||
|
// .then((it) => ({
|
||||||
|
// albums: it.album.map(asAlbum),
|
||||||
|
// }));
|
||||||
|
|
||||||
|
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) =>
|
||||||
|
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))
|
||||||
|
),
|
||||||
|
track: (trackId: string) => subsonic.getTrack(credentials, trackId),
|
||||||
|
rate: (trackId: string, rating: Rating) =>
|
||||||
|
Promise.resolve(true)
|
||||||
|
.then(() => {
|
||||||
|
if (rating.stars >= 0 && rating.stars <= 5) {
|
||||||
|
return subsonic.getTrack(credentials, trackId);
|
||||||
|
} else {
|
||||||
|
throw `Invalid rating.stars value of ${rating.stars}`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((track) => {
|
||||||
|
const thingsToUpdate = [];
|
||||||
|
if (track.rating.love != rating.love) {
|
||||||
|
thingsToUpdate.push(
|
||||||
|
subsonic.getJSON(
|
||||||
|
credentials,
|
||||||
|
`/rest/${rating.love ? "star" : "unstar"}`,
|
||||||
|
{
|
||||||
|
id: trackId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (track.rating.stars != rating.stars) {
|
||||||
|
thingsToUpdate.push(
|
||||||
|
subsonic.getJSON(credentials, `/rest/setRating`, {
|
||||||
|
id: trackId,
|
||||||
|
rating: rating.stars,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.all(thingsToUpdate);
|
||||||
|
})
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
stream: async ({
|
||||||
|
trackId,
|
||||||
|
range,
|
||||||
|
}: {
|
||||||
|
trackId: string;
|
||||||
|
range: string | undefined;
|
||||||
|
}) =>
|
||||||
|
subsonic.getTrack(credentials, trackId).then((track) =>
|
||||||
|
subsonic
|
||||||
|
.get(
|
||||||
|
credentials,
|
||||||
|
`/rest/stream`,
|
||||||
|
{
|
||||||
|
id: trackId,
|
||||||
|
c: this.streamClientApplication(track),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: pipe(
|
||||||
|
range,
|
||||||
|
O.fromNullable,
|
||||||
|
O.map((range) => ({
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
Range: range,
|
||||||
|
})),
|
||||||
|
O.getOrElse(() => ({
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
responseType: "stream",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((res) => ({
|
||||||
|
status: res.status,
|
||||||
|
headers: {
|
||||||
|
"content-type": res.headers["content-type"],
|
||||||
|
"content-length": res.headers["content-length"],
|
||||||
|
"content-range": res.headers["content-range"],
|
||||||
|
"accept-ranges": res.headers["accept-ranges"],
|
||||||
|
},
|
||||||
|
stream: res.data,
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
coverArt: async (coverArtURN: BUrn, size?: number) =>
|
||||||
|
Promise.resolve(coverArtURN)
|
||||||
|
.then((it) => assertSystem(it, "subsonic"))
|
||||||
|
.then((it) => it.resource.split(":")[1]!)
|
||||||
|
.then((it) => subsonic.getCoverArt(credentials, it, size))
|
||||||
|
.then((res) => ({
|
||||||
|
contentType: res.headers["content-type"],
|
||||||
|
data: Buffer.from(res.data, "binary"),
|
||||||
|
}))
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed getting coverArt for urn:'${coverArtURN}': ${e}`
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
scrobble: async (id: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON(credentials, `/rest/scrobble`, {
|
||||||
|
id,
|
||||||
|
submission: true,
|
||||||
|
})
|
||||||
|
.then((_) => true)
|
||||||
|
.catch(() => false),
|
||||||
|
nowPlaying: async (id: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON(credentials, `/rest/scrobble`, {
|
||||||
|
id,
|
||||||
|
submission: false,
|
||||||
|
})
|
||||||
|
.then((_) => true)
|
||||||
|
.catch(() => false),
|
||||||
|
searchArtists: async (query: string) =>
|
||||||
|
subsonic
|
||||||
|
.search3(credentials, { query, artistCount: 20 })
|
||||||
|
.then(({ artists }) =>
|
||||||
|
artists.map((artist) => ({
|
||||||
|
id: artist.id,
|
||||||
|
name: artist.name,
|
||||||
|
image: artistImageURN({
|
||||||
|
artistId: artist.id,
|
||||||
|
artistImageURL: artist.artistImageUrl,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
searchAlbums: async (query: string) =>
|
||||||
|
subsonic
|
||||||
|
.search3(credentials, { query, albumCount: 20 })
|
||||||
|
.then(({ albums }) => subsonic.toAlbumSummary(albums)),
|
||||||
|
searchTracks: async (query: string) =>
|
||||||
|
subsonic
|
||||||
|
.search3(credentials, { query, songCount: 20 })
|
||||||
|
.then(({ songs }) =>
|
||||||
|
Promise.all(
|
||||||
|
songs.map((it) => subsonic.getTrack(credentials, it.id))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
playlists: async () =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
|
||||||
|
.then((it) => it.playlists.playlist || [])
|
||||||
|
.then((playlists) =>
|
||||||
|
playlists.map((it) => ({ id: it.id, name: it.name }))
|
||||||
|
),
|
||||||
|
playlist: async (id: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", {
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
.then((it) => it.playlist)
|
||||||
|
.then((playlist) => {
|
||||||
|
let trackNumber = 1;
|
||||||
|
return {
|
||||||
|
id: playlist.id,
|
||||||
|
name: playlist.name,
|
||||||
|
entries: (playlist.entry || []).map((entry) => ({
|
||||||
|
...asTrack(
|
||||||
|
{
|
||||||
|
id: entry.albumId!,
|
||||||
|
name: entry.album!,
|
||||||
|
year: entry.year,
|
||||||
|
genre: maybeAsGenre(entry.genre),
|
||||||
|
artistName: entry.artist,
|
||||||
|
artistId: entry.artistId,
|
||||||
|
coverArt: coverArtURN(entry.coverArt),
|
||||||
|
},
|
||||||
|
entry
|
||||||
|
),
|
||||||
|
number: trackNumber++,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
createPlaylist: async (name: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(credentials, "/rest/createPlaylist", {
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
.then((it) => it.playlist)
|
||||||
|
.then((it) => ({ id: it.id, name: it.name })),
|
||||||
|
deletePlaylist: async (id: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", {
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
.then((_) => true),
|
||||||
|
addToPlaylist: async (playlistId: string, trackId: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", {
|
||||||
|
playlistId,
|
||||||
|
songIdToAdd: trackId,
|
||||||
|
})
|
||||||
|
.then((_) => true),
|
||||||
|
removeFromPlaylist: async (playlistId: string, indicies: number[]) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", {
|
||||||
|
playlistId,
|
||||||
|
songIndexToRemove: indicies,
|
||||||
|
})
|
||||||
|
.then((_) => true),
|
||||||
|
similarSongs: async (id: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetSimilarSongsResponse>(
|
||||||
|
credentials,
|
||||||
|
"/rest/getSimilarSongs2",
|
||||||
|
{ id, count: 50 }
|
||||||
|
)
|
||||||
|
.then((it) => it.similarSongs2.song || [])
|
||||||
|
.then((songs) =>
|
||||||
|
Promise.all(
|
||||||
|
songs.map((song) =>
|
||||||
|
subsonic
|
||||||
|
.getAlbum(credentials, song.albumId!)
|
||||||
|
.then((album) => asTrack(album, song))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
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))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (credentials.type == "navidrome") {
|
||||||
|
return Promise.resolve({
|
||||||
|
...genericSubsonic,
|
||||||
|
flavour: () => "navidrome",
|
||||||
|
bearerToken: (credentials: Credentials) =>
|
||||||
|
pipe(
|
||||||
|
TE.tryCatch(
|
||||||
|
() =>
|
||||||
|
axios.post(
|
||||||
|
`${this.url}/auth/login`,
|
||||||
|
_.pick(credentials, "username", "password")
|
||||||
|
),
|
||||||
|
() => new AuthFailure("Failed to get bearerToken")
|
||||||
|
),
|
||||||
|
TE.map((it) => it.data.token as string | undefined)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return Promise.resolve(genericSubsonic);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,770 +0,0 @@
|
|||||||
import { option as O, taskEither as TE } from "fp-ts";
|
|
||||||
import * as A from "fp-ts/Array";
|
|
||||||
import { pipe } from "fp-ts/lib/function";
|
|
||||||
import { ordString } from "fp-ts/lib/Ord";
|
|
||||||
import { inject } from "underscore";
|
|
||||||
import _ from "underscore";
|
|
||||||
|
|
||||||
import logger from "../logger";
|
|
||||||
import { b64Decode, b64Encode } from "../b64";
|
|
||||||
import { assertSystem, BUrn, format } from "../burn";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Album,
|
|
||||||
AlbumQuery,
|
|
||||||
AlbumQueryType,
|
|
||||||
AlbumSummary,
|
|
||||||
Artist,
|
|
||||||
ArtistQuery,
|
|
||||||
ArtistSummary,
|
|
||||||
AuthFailure,
|
|
||||||
Credentials,
|
|
||||||
Genre,
|
|
||||||
IdName,
|
|
||||||
Rating,
|
|
||||||
Result,
|
|
||||||
slice2,
|
|
||||||
Sortable,
|
|
||||||
Track,
|
|
||||||
} from "../music_service";
|
|
||||||
import {
|
|
||||||
DODGY_IMAGE_NAME,
|
|
||||||
StreamClientApplication,
|
|
||||||
SubsonicCredentials,
|
|
||||||
SubsonicMusicLibrary,
|
|
||||||
SubsonicResponse,
|
|
||||||
USER_AGENT,
|
|
||||||
} from ".";
|
|
||||||
import axios from "axios";
|
|
||||||
import { asURLSearchParams } from "../utils";
|
|
||||||
import { artistSummaryFromNDArtist, NDArtist } from "./navidrome";
|
|
||||||
import { Http2, RequestParams } from "../http";
|
|
||||||
import { client } from "./subsonic_http";
|
|
||||||
|
|
||||||
type album = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
artist: string | undefined;
|
|
||||||
artistId: string | undefined;
|
|
||||||
coverArt: string | undefined;
|
|
||||||
genre: string | undefined;
|
|
||||||
year: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type artist = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
albumCount: number;
|
|
||||||
artistImageUrl: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetArtistsResponse = SubsonicResponse & {
|
|
||||||
artists: {
|
|
||||||
index: {
|
|
||||||
artist: artist[];
|
|
||||||
name: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetAlbumListResponse = SubsonicResponse & {
|
|
||||||
albumList2: {
|
|
||||||
album: album[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type genre = {
|
|
||||||
songCount: number;
|
|
||||||
albumCount: number;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetGenresResponse = SubsonicResponse & {
|
|
||||||
genres: {
|
|
||||||
genre: genre[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetArtistInfoResponse = SubsonicResponse & {
|
|
||||||
artistInfo2: artistInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetArtistResponse = SubsonicResponse & {
|
|
||||||
artist: artist & {
|
|
||||||
album: album[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type images = {
|
|
||||||
smallImageUrl: string | undefined;
|
|
||||||
mediumImageUrl: string | undefined;
|
|
||||||
largeImageUrl: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type artistInfo = images & {
|
|
||||||
biography: string | undefined;
|
|
||||||
musicBrainzId: string | undefined;
|
|
||||||
lastFmUrl: string | undefined;
|
|
||||||
similarArtist: artist[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type song = {
|
|
||||||
id: string;
|
|
||||||
parent: string | undefined;
|
|
||||||
title: string;
|
|
||||||
album: string | undefined;
|
|
||||||
albumId: string | undefined;
|
|
||||||
artist: string | undefined;
|
|
||||||
artistId: string | undefined;
|
|
||||||
track: number | undefined;
|
|
||||||
year: string | undefined;
|
|
||||||
genre: string | undefined;
|
|
||||||
coverArt: string | undefined;
|
|
||||||
created: string | undefined;
|
|
||||||
duration: number | undefined;
|
|
||||||
bitRate: number | undefined;
|
|
||||||
suffix: string | undefined;
|
|
||||||
contentType: string | undefined;
|
|
||||||
type: string | undefined;
|
|
||||||
userRating: number | undefined;
|
|
||||||
starred: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetAlbumResponse = {
|
|
||||||
album: album & {
|
|
||||||
song: song[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type playlist = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetPlaylistResponse = {
|
|
||||||
playlist: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
entry: song[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetPlaylistsResponse = {
|
|
||||||
playlists: { playlist: playlist[] };
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetSimilarSongsResponse = {
|
|
||||||
similarSongs2: { song: song[] };
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetTopSongsResponse = {
|
|
||||||
topSongs: { song: song[] };
|
|
||||||
};
|
|
||||||
|
|
||||||
type GetSongResponse = {
|
|
||||||
song: song;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Search3Response = SubsonicResponse & {
|
|
||||||
searchResult3: {
|
|
||||||
artist: artist[];
|
|
||||||
album: album[];
|
|
||||||
song: song[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
|
||||||
alphabeticalByArtist: "alphabeticalByArtist",
|
|
||||||
alphabeticalByName: "alphabeticalByName",
|
|
||||||
byGenre: "byGenre",
|
|
||||||
random: "random",
|
|
||||||
recentlyPlayed: "recent",
|
|
||||||
mostPlayed: "frequent",
|
|
||||||
recentlyAdded: "newest",
|
|
||||||
favourited: "starred",
|
|
||||||
starred: "highest",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isValidImage = (url: string | undefined) =>
|
|
||||||
url != undefined && !url.endsWith(DODGY_IMAGE_NAME);
|
|
||||||
|
|
||||||
const artistIsInLibrary = (artistId: string | undefined) =>
|
|
||||||
artistId != undefined && artistId != "-1";
|
|
||||||
|
|
||||||
const coverArtURN = (coverArt: string | undefined): BUrn | undefined =>
|
|
||||||
pipe(
|
|
||||||
coverArt,
|
|
||||||
O.fromNullable,
|
|
||||||
O.map((it: string) => ({ system: "subsonic", resource: `art:${it}` })),
|
|
||||||
O.getOrElseW(() => undefined)
|
|
||||||
);
|
|
||||||
|
|
||||||
// todo: is this the right place for this??
|
|
||||||
export const artistImageURN = (
|
|
||||||
spec: Partial<{
|
|
||||||
artistId: string | undefined;
|
|
||||||
artistImageURL: string | undefined;
|
|
||||||
}>
|
|
||||||
): BUrn | undefined => {
|
|
||||||
const deets = {
|
|
||||||
artistId: undefined,
|
|
||||||
artistImageURL: undefined,
|
|
||||||
...spec,
|
|
||||||
};
|
|
||||||
if (deets.artistImageURL && isValidImage(deets.artistImageURL)) {
|
|
||||||
return {
|
|
||||||
system: "external",
|
|
||||||
resource: deets.artistImageURL,
|
|
||||||
};
|
|
||||||
} else if (artistIsInLibrary(deets.artistId)) {
|
|
||||||
return {
|
|
||||||
system: "subsonic",
|
|
||||||
resource: `art:${deets.artistId!}`,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const asTrack = (album: Album, song: song): Track => ({
|
|
||||||
id: song.id,
|
|
||||||
name: song.title,
|
|
||||||
mimeType: song.contentType!,
|
|
||||||
duration: song.duration || 0,
|
|
||||||
number: song.track || 0,
|
|
||||||
genre: maybeAsGenre(song.genre),
|
|
||||||
coverArt: coverArtURN(song.coverArt),
|
|
||||||
album,
|
|
||||||
artist: {
|
|
||||||
id: song.artistId,
|
|
||||||
name: song.artist ? song.artist : "?",
|
|
||||||
image: song.artistId
|
|
||||||
? artistImageURN({ artistId: song.artistId })
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
rating: {
|
|
||||||
love: song.starred != undefined,
|
|
||||||
stars:
|
|
||||||
song.userRating && song.userRating <= 5 && song.userRating >= 0
|
|
||||||
? song.userRating
|
|
||||||
: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const asAlbum = (album: album): Album => ({
|
|
||||||
id: album.id,
|
|
||||||
name: album.name,
|
|
||||||
year: album.year,
|
|
||||||
genre: maybeAsGenre(album.genre),
|
|
||||||
artistId: album.artistId,
|
|
||||||
artistName: album.artist,
|
|
||||||
coverArt: coverArtURN(album.coverArt),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const asGenre = (genreName: string) => ({
|
|
||||||
id: b64Encode(genreName),
|
|
||||||
name: genreName,
|
|
||||||
});
|
|
||||||
|
|
||||||
const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
|
||||||
pipe(
|
|
||||||
genreName,
|
|
||||||
O.fromNullable,
|
|
||||||
O.map(asGenre),
|
|
||||||
O.getOrElseW(() => undefined)
|
|
||||||
);
|
|
||||||
|
|
||||||
export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
|
|
||||||
streamClientApplication: StreamClientApplication;
|
|
||||||
subsonicHttp: Http2;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
streamClientApplication: StreamClientApplication,
|
|
||||||
subsonicHttp: Http2
|
|
||||||
) {
|
|
||||||
this.streamClientApplication = streamClientApplication;
|
|
||||||
this.subsonicHttp = subsonicHttp;
|
|
||||||
}
|
|
||||||
|
|
||||||
GET = (query: Partial<RequestParams>) => client(this.subsonicHttp)({ method: 'get', ...query });
|
|
||||||
|
|
||||||
flavour = () => "subsonic";
|
|
||||||
|
|
||||||
bearerToken = (_: Credentials): TE.TaskEither<Error, string | undefined> =>
|
|
||||||
TE.right(undefined);
|
|
||||||
|
|
||||||
artists = async (q: ArtistQuery): Promise<Result<ArtistSummary & Sortable>> =>
|
|
||||||
this.getArtists()
|
|
||||||
.then(slice2(q))
|
|
||||||
.then(([page, total]) => ({
|
|
||||||
total,
|
|
||||||
results: page.map((it) => ({
|
|
||||||
id: it.id,
|
|
||||||
name: it.name,
|
|
||||||
sortName: it.name,
|
|
||||||
image: it.image,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
artist = async (id: string): Promise<Artist> => this.getArtistWithInfo(id);
|
|
||||||
|
|
||||||
albums = async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
|
|
||||||
this.getAlbumList2(q);
|
|
||||||
|
|
||||||
album = (id: string): Promise<Album> => this.getAlbum(id);
|
|
||||||
|
|
||||||
genres = () =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/getGenres",
|
|
||||||
})
|
|
||||||
.asJSON<GetGenresResponse>()
|
|
||||||
.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) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/getAlbum",
|
|
||||||
params: {
|
|
||||||
id: albumId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<GetAlbumResponse>()
|
|
||||||
.then((it) => it.album)
|
|
||||||
.then((album) =>
|
|
||||||
(album.song || []).map((song) => asTrack(asAlbum(album), song))
|
|
||||||
);
|
|
||||||
|
|
||||||
track = (trackId: string) => this.getTrack(trackId);
|
|
||||||
|
|
||||||
rate = (trackId: string, rating: Rating) =>
|
|
||||||
Promise.resolve(true)
|
|
||||||
.then(() => {
|
|
||||||
if (rating.stars >= 0 && rating.stars <= 5) {
|
|
||||||
return this.getTrack(trackId);
|
|
||||||
} else {
|
|
||||||
throw `Invalid rating.stars value of ${rating.stars}`;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((track) => {
|
|
||||||
const thingsToUpdate = [];
|
|
||||||
if (track.rating.love != rating.love) {
|
|
||||||
thingsToUpdate.push(
|
|
||||||
this.GET({
|
|
||||||
url: `/rest/${rating.love ? "star" : "unstar"}`,
|
|
||||||
params: {
|
|
||||||
id: trackId,
|
|
||||||
},
|
|
||||||
}).asJSON()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (track.rating.stars != rating.stars) {
|
|
||||||
thingsToUpdate.push(
|
|
||||||
this.GET({
|
|
||||||
url: `/rest/setRating`,
|
|
||||||
params: {
|
|
||||||
id: trackId,
|
|
||||||
rating: rating.stars,
|
|
||||||
},
|
|
||||||
}).asJSON()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Promise.all(thingsToUpdate);
|
|
||||||
})
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
stream = async ({
|
|
||||||
trackId,
|
|
||||||
range,
|
|
||||||
}: {
|
|
||||||
trackId: string;
|
|
||||||
range: string | undefined;
|
|
||||||
}) =>
|
|
||||||
this.getTrack(trackId).then((track) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/stream",
|
|
||||||
params: {
|
|
||||||
id: trackId,
|
|
||||||
c: this.streamClientApplication(track),
|
|
||||||
},
|
|
||||||
headers: range != undefined ? { Range: range } : {},
|
|
||||||
responseType: "stream",
|
|
||||||
})
|
|
||||||
.asRaw()
|
|
||||||
.then((res) => ({
|
|
||||||
status: res.status,
|
|
||||||
headers: {
|
|
||||||
"content-type": res.headers["content-type"],
|
|
||||||
"content-length": res.headers["content-length"],
|
|
||||||
"content-range": res.headers["content-range"],
|
|
||||||
"accept-ranges": res.headers["accept-ranges"],
|
|
||||||
},
|
|
||||||
stream: res.data,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
coverArt = async (coverArtURN: BUrn, size?: number) =>
|
|
||||||
Promise.resolve(coverArtURN)
|
|
||||||
.then((it) => assertSystem(it, "subsonic"))
|
|
||||||
.then((it) => it.resource.split(":")[1]!)
|
|
||||||
.then((it) => this.getCoverArt(it, size))
|
|
||||||
.then((res) => ({
|
|
||||||
contentType: res.headers["content-type"],
|
|
||||||
data: Buffer.from(res.data, "binary"),
|
|
||||||
}))
|
|
||||||
.catch((e) => {
|
|
||||||
logger.error(
|
|
||||||
`Failed getting coverArt for '${format(coverArtURN)}': ${e}`
|
|
||||||
);
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
scrobble = async (id: string) =>
|
|
||||||
this.GET({
|
|
||||||
url: `/rest/scrobble`,
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
submission: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON()
|
|
||||||
.then((_) => true)
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
nowPlaying = async (id: string) =>
|
|
||||||
this.GET({
|
|
||||||
url: `/rest/scrobble`,
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
submission: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON()
|
|
||||||
.then((_) => true)
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
searchArtists = async (query: string) =>
|
|
||||||
this.search3({ 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.search3({ query, albumCount: 20 }).then(({ albums }) =>
|
|
||||||
this.toAlbumSummary(albums)
|
|
||||||
);
|
|
||||||
|
|
||||||
searchTracks = async (query: string) =>
|
|
||||||
this.search3({ query, songCount: 20 }).then(({ songs }) =>
|
|
||||||
Promise.all(songs.map((it) => this.getTrack(it.id)))
|
|
||||||
);
|
|
||||||
|
|
||||||
playlists = async () =>
|
|
||||||
this.GET({ url: "/rest/getPlaylists" })
|
|
||||||
.asJSON<GetPlaylistsResponse>()
|
|
||||||
.then((it) => it.playlists.playlist || [])
|
|
||||||
.then((playlists) =>
|
|
||||||
playlists.map((it) => ({ id: it.id, name: it.name }))
|
|
||||||
);
|
|
||||||
|
|
||||||
playlist = async (id: string) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/getPlaylist",
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<GetPlaylistResponse>()
|
|
||||||
.then((it) => it.playlist)
|
|
||||||
.then((playlist) => {
|
|
||||||
let trackNumber = 1;
|
|
||||||
return {
|
|
||||||
id: playlist.id,
|
|
||||||
name: playlist.name,
|
|
||||||
entries: (playlist.entry || []).map((entry) => ({
|
|
||||||
...asTrack(
|
|
||||||
{
|
|
||||||
id: entry.albumId!,
|
|
||||||
name: entry.album!,
|
|
||||||
year: entry.year,
|
|
||||||
genre: maybeAsGenre(entry.genre),
|
|
||||||
artistName: entry.artist,
|
|
||||||
artistId: entry.artistId,
|
|
||||||
coverArt: coverArtURN(entry.coverArt),
|
|
||||||
},
|
|
||||||
entry
|
|
||||||
),
|
|
||||||
number: trackNumber++,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
createPlaylist = async (name: string) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/createPlaylist",
|
|
||||||
params: {
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<GetPlaylistResponse>()
|
|
||||||
.then((it) => it.playlist)
|
|
||||||
.then((it) => ({ id: it.id, name: it.name }));
|
|
||||||
|
|
||||||
deletePlaylist = async (id: string) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/deletePlaylist",
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<GetPlaylistResponse>()
|
|
||||||
.then((_) => true);
|
|
||||||
|
|
||||||
addToPlaylist = async (playlistId: string, trackId: string) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/updatePlaylist",
|
|
||||||
params: {
|
|
||||||
playlistId,
|
|
||||||
songIdToAdd: trackId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<GetPlaylistResponse>()
|
|
||||||
.then((_) => true);
|
|
||||||
|
|
||||||
removeFromPlaylist = async (playlistId: string, indicies: number[]) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/updatePlaylist",
|
|
||||||
params: {
|
|
||||||
playlistId,
|
|
||||||
songIndexToRemove: indicies,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<GetPlaylistResponse>()
|
|
||||||
.then((_) => true);
|
|
||||||
|
|
||||||
similarSongs = async (id: string) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/getSimilarSongs2",
|
|
||||||
params: { id, count: 50 },
|
|
||||||
})
|
|
||||||
.asJSON<GetSimilarSongsResponse>()
|
|
||||||
.then((it) => it.similarSongs2.song || [])
|
|
||||||
.then((songs) =>
|
|
||||||
Promise.all(
|
|
||||||
songs.map((song) =>
|
|
||||||
this.getAlbum(song.albumId!).then((album) => asTrack(album, song))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
topSongs = async (artistId: string) =>
|
|
||||||
this.getArtist(artistId).then(({ name }) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/getTopSongs",
|
|
||||||
params: {
|
|
||||||
artist: name,
|
|
||||||
count: 50,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<GetTopSongsResponse>()
|
|
||||||
.then((it) => it.topSongs.song || [])
|
|
||||||
.then((songs) =>
|
|
||||||
Promise.all(
|
|
||||||
songs.map((song) =>
|
|
||||||
this.getAlbum(song.albumId!).then((album) => asTrack(album, song))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
private getArtists = (): Promise<
|
|
||||||
(IdName & { albumCount: number; image: BUrn | undefined })[]
|
|
||||||
> =>
|
|
||||||
this.GET({ url: "/rest/getArtists" })
|
|
||||||
.asJSON<GetArtistsResponse>()
|
|
||||||
.then((it) => (it.artists.index || []).flatMap((it) => it.artist || []))
|
|
||||||
.then((artists) =>
|
|
||||||
artists.map((artist) => ({
|
|
||||||
id: `${artist.id}`,
|
|
||||||
name: artist.name,
|
|
||||||
albumCount: artist.albumCount,
|
|
||||||
image: artistImageURN({
|
|
||||||
artistId: artist.id,
|
|
||||||
artistImageURL: artist.artistImageUrl,
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
private getArtistInfo = (
|
|
||||||
id: string
|
|
||||||
): Promise<{
|
|
||||||
similarArtist: (ArtistSummary & { inLibrary: boolean })[];
|
|
||||||
images: {
|
|
||||||
s: string | undefined;
|
|
||||||
m: string | undefined;
|
|
||||||
l: string | undefined;
|
|
||||||
};
|
|
||||||
}> =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/getArtistInfo2",
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
count: 50,
|
|
||||||
includeNotPresent: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<GetArtistInfoResponse>()
|
|
||||||
.then((it) => it.artistInfo2)
|
|
||||||
.then((it) => ({
|
|
||||||
images: {
|
|
||||||
s: it.smallImageUrl,
|
|
||||||
m: it.mediumImageUrl,
|
|
||||||
l: it.largeImageUrl,
|
|
||||||
},
|
|
||||||
similarArtist: (it.similarArtist || []).map((artist) => ({
|
|
||||||
id: `${artist.id}`,
|
|
||||||
name: artist.name,
|
|
||||||
inLibrary: artistIsInLibrary(artist.id),
|
|
||||||
image: artistImageURN({
|
|
||||||
artistId: artist.id,
|
|
||||||
artistImageURL: artist.artistImageUrl,
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
private getAlbum = (id: string): Promise<Album> =>
|
|
||||||
this.GET({ url: "/rest/getAlbum", params: { id } })
|
|
||||||
.asJSON<GetAlbumResponse>()
|
|
||||||
.then((it) => it.album)
|
|
||||||
.then((album) => ({
|
|
||||||
id: album.id,
|
|
||||||
name: album.name,
|
|
||||||
year: album.year,
|
|
||||||
genre: maybeAsGenre(album.genre),
|
|
||||||
artistId: album.artistId,
|
|
||||||
artistName: album.artist,
|
|
||||||
coverArt: coverArtURN(album.coverArt),
|
|
||||||
}));
|
|
||||||
|
|
||||||
private getArtist = (
|
|
||||||
id: string
|
|
||||||
): Promise<
|
|
||||||
IdName & { artistImageUrl: string | undefined; albums: AlbumSummary[] }
|
|
||||||
> =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/getArtist",
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<GetArtistResponse>()
|
|
||||||
.then((it) => it.artist)
|
|
||||||
.then((it) => ({
|
|
||||||
id: it.id,
|
|
||||||
name: it.name,
|
|
||||||
artistImageUrl: it.artistImageUrl,
|
|
||||||
albums: this.toAlbumSummary(it.album || []),
|
|
||||||
}));
|
|
||||||
|
|
||||||
private getArtistWithInfo = (id: string) =>
|
|
||||||
Promise.all([this.getArtist(id), this.getArtistInfo(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,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
private getCoverArt = (id: string, size?: number) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/getCoverArt",
|
|
||||||
params: { id, size },
|
|
||||||
responseType: "arraybuffer",
|
|
||||||
}).asRaw();
|
|
||||||
|
|
||||||
private getTrack = (id: string) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/getSong",
|
|
||||||
params: {
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<GetSongResponse>()
|
|
||||||
.then((it) => it.song)
|
|
||||||
.then((song) =>
|
|
||||||
this.getAlbum(song.albumId!).then((album) => asTrack(album, song))
|
|
||||||
);
|
|
||||||
|
|
||||||
private toAlbumSummary = (albumList: album[]): AlbumSummary[] =>
|
|
||||||
albumList.map((album) => ({
|
|
||||||
id: album.id,
|
|
||||||
name: album.name,
|
|
||||||
year: album.year,
|
|
||||||
genre: maybeAsGenre(album.genre),
|
|
||||||
artistId: album.artistId,
|
|
||||||
artistName: album.artist,
|
|
||||||
coverArt: coverArtURN(album.coverArt),
|
|
||||||
}));
|
|
||||||
|
|
||||||
private search3 = (q: any) =>
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/search3",
|
|
||||||
params: {
|
|
||||||
artistCount: 0,
|
|
||||||
albumCount: 0,
|
|
||||||
songCount: 0,
|
|
||||||
...q,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<Search3Response>()
|
|
||||||
.then((it) => ({
|
|
||||||
artists: it.searchResult3.artist || [],
|
|
||||||
albums: it.searchResult3.album || [],
|
|
||||||
songs: it.searchResult3.song || [],
|
|
||||||
}));
|
|
||||||
|
|
||||||
private getAlbumList2 = (q: AlbumQuery) =>
|
|
||||||
Promise.all([
|
|
||||||
this.getArtists().then((it) =>
|
|
||||||
inject(it, (total, artist) => total + artist.albumCount, 0)
|
|
||||||
),
|
|
||||||
this.GET({
|
|
||||||
url: "/rest/getAlbumList2",
|
|
||||||
params: {
|
|
||||||
type: AlbumQueryTypeToSubsonicType[q.type],
|
|
||||||
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
|
|
||||||
size: 500,
|
|
||||||
offset: q._index,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.asJSON<GetAlbumListResponse>()
|
|
||||||
.then((response) => response.albumList2.album || [])
|
|
||||||
.then(this.toAlbumSummary),
|
|
||||||
]).then(([total, albums]) => ({
|
|
||||||
results: albums.slice(0, q._count),
|
|
||||||
total: albums.length == 500 ? total : (q._index || 0) + albums.length,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { taskEither as TE } from "fp-ts";
|
|
||||||
import { pipe } from "fp-ts/lib/function";
|
|
||||||
import { Md5 } from "ts-md5/dist/md5";
|
|
||||||
import axios from "axios";
|
|
||||||
import randomstring from "randomstring";
|
|
||||||
import _ from "underscore";
|
|
||||||
// todo: rename http2 to http
|
|
||||||
import { Http2, http2From } from "../http";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Credentials,
|
|
||||||
MusicService,
|
|
||||||
MusicLibrary,
|
|
||||||
Track,
|
|
||||||
AuthFailure,
|
|
||||||
} from "../music_service";
|
|
||||||
import { b64Encode, b64Decode } from "../b64";
|
|
||||||
import { axiosImageFetcher, ImageFetcher } from "../images";
|
|
||||||
import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./generic";
|
|
||||||
import { client } from "./subsonic_http";
|
|
||||||
|
|
||||||
export const t = (password: string, s: string) =>
|
|
||||||
Md5.hashStr(`${password}${s}`);
|
|
||||||
|
|
||||||
export const t_and_s = (password: string) => {
|
|
||||||
const s = randomstring.generate();
|
|
||||||
return {
|
|
||||||
t: t(password, s),
|
|
||||||
s,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// todo: this is an ND thing
|
|
||||||
export const DODGY_IMAGE_NAME = "2a96cbd8b46e442fc41c2b86b821562f.png";
|
|
||||||
|
|
||||||
export type SubsonicEnvelope = {
|
|
||||||
"subsonic-response": SubsonicResponse;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SubsonicResponse = {
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SubsonicError = SubsonicResponse & {
|
|
||||||
error: {
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PingResponse = {
|
|
||||||
status: string;
|
|
||||||
version: string;
|
|
||||||
type: string;
|
|
||||||
serverVersion: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isError(
|
|
||||||
subsonicResponse: SubsonicResponse
|
|
||||||
): subsonicResponse is SubsonicError {
|
|
||||||
return (subsonicResponse as SubsonicError).error !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo: is this a good name?
|
|
||||||
export type StreamClientApplication = (track: Track) => string;
|
|
||||||
|
|
||||||
export const DEFAULT_CLIENT_APPLICATION = "bonob";
|
|
||||||
export const USER_AGENT = "bonob";
|
|
||||||
|
|
||||||
export const DEFAULT: StreamClientApplication = (_: Track) =>
|
|
||||||
DEFAULT_CLIENT_APPLICATION;
|
|
||||||
|
|
||||||
export function appendMimeTypeToClientFor(mimeTypes: string[]) {
|
|
||||||
return (track: Track) =>
|
|
||||||
mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob";
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SubsonicCredentials = Credentials & {
|
|
||||||
type: string;
|
|
||||||
bearer: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const asToken = (credentials: SubsonicCredentials) =>
|
|
||||||
b64Encode(JSON.stringify(credentials));
|
|
||||||
|
|
||||||
export const parseToken = (token: string): SubsonicCredentials =>
|
|
||||||
JSON.parse(b64Decode(token));
|
|
||||||
|
|
||||||
export interface SubsonicMusicLibrary extends MusicLibrary {
|
|
||||||
flavour(): string;
|
|
||||||
bearerToken(
|
|
||||||
credentials: Credentials
|
|
||||||
): TE.TaskEither<Error, string | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Subsonic implements MusicService {
|
|
||||||
url: string;
|
|
||||||
|
|
||||||
// todo: does this need to be in here now?
|
|
||||||
streamClientApplication: StreamClientApplication;
|
|
||||||
// todo: why is this in here?
|
|
||||||
externalImageFetcher: ImageFetcher;
|
|
||||||
|
|
||||||
subsonicHttp: Http2;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
url: string,
|
|
||||||
streamClientApplication: StreamClientApplication = DEFAULT,
|
|
||||||
externalImageFetcher: ImageFetcher = axiosImageFetcher
|
|
||||||
) {
|
|
||||||
this.url = url;
|
|
||||||
this.streamClientApplication = streamClientApplication;
|
|
||||||
this.externalImageFetcher = externalImageFetcher;
|
|
||||||
this.subsonicHttp = http2From(axios).with({
|
|
||||||
baseURL: this.url,
|
|
||||||
params: { v: "1.16.1", c: DEFAULT_CLIENT_APPLICATION },
|
|
||||||
headers: { "User-Agent": "bonob" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
asAuthParams = (credentials: Credentials) => ({
|
|
||||||
u: credentials.username,
|
|
||||||
...t_and_s(credentials.password),
|
|
||||||
})
|
|
||||||
|
|
||||||
generateToken = (credentials: Credentials) =>
|
|
||||||
pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() => client(this.subsonicHttp.with({ params: this.asAuthParams(credentials) } ))({ method: 'get', url: "/rest/ping.view" }).asJSON<PingResponse>(),
|
|
||||||
(e) => new AuthFailure(e as string)
|
|
||||||
),
|
|
||||||
TE.chain(({ type }) =>
|
|
||||||
pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() => this.libraryFor({ ...credentials, type, bearer: undefined }),
|
|
||||||
() => 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));
|
|
||||||
|
|
||||||
login = async (token: string) => this.libraryFor(parseToken(token));
|
|
||||||
|
|
||||||
private libraryFor = (
|
|
||||||
credentials: SubsonicCredentials
|
|
||||||
): Promise<SubsonicMusicLibrary> => {
|
|
||||||
const subsonicGenericLibrary = new SubsonicGenericMusicLibrary(
|
|
||||||
this.streamClientApplication,
|
|
||||||
this.subsonicHttp.with({ params: this.asAuthParams(credentials) } )
|
|
||||||
);
|
|
||||||
if (credentials.type == "navidrome") {
|
|
||||||
return Promise.resolve(
|
|
||||||
navidromeMusicLibrary(this.url, subsonicGenericLibrary, credentials)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Promise.resolve(subsonicGenericLibrary);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Subsonic;
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { option as O, taskEither as TE } from "fp-ts";
|
|
||||||
import * as A from "fp-ts/Array";
|
|
||||||
import { pipe } from "fp-ts/lib/function";
|
|
||||||
import { ordString } from "fp-ts/lib/Ord";
|
|
||||||
import { inject } from "underscore";
|
|
||||||
import _ from "underscore";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
import { SubsonicCredentials, SubsonicMusicLibrary } from ".";
|
|
||||||
import { ArtistQuery, ArtistSummary, AuthFailure, Credentials, Result, Sortable } from "../music_service";
|
|
||||||
import { artistImageURN } from "./generic";
|
|
||||||
|
|
||||||
export type NDArtist = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
orderArtistName: string | undefined;
|
|
||||||
largeImageUrl: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const artistSummaryFromNDArtist = (
|
|
||||||
artist: NDArtist
|
|
||||||
): ArtistSummary & Sortable => ({
|
|
||||||
id: artist.id,
|
|
||||||
name: artist.name,
|
|
||||||
sortName: artist.orderArtistName || artist.name,
|
|
||||||
image: artistImageURN({
|
|
||||||
artistId: artist.id,
|
|
||||||
artistImageURL: artist.largeImageUrl,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
export const navidromeMusicLibrary = (
|
|
||||||
url: string,
|
|
||||||
subsonicLibrary: SubsonicMusicLibrary,
|
|
||||||
subsonicCredentials: SubsonicCredentials
|
|
||||||
): SubsonicMusicLibrary => ({
|
|
||||||
...subsonicLibrary,
|
|
||||||
flavour: () => "navidrome",
|
|
||||||
bearerToken: (
|
|
||||||
credentials: Credentials
|
|
||||||
): TE.TaskEither<Error, string | undefined> =>
|
|
||||||
pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
// todo: not hardcode axios in here
|
|
||||||
axios({
|
|
||||||
method: "post",
|
|
||||||
baseURL: url,
|
|
||||||
url: `/auth/login`,
|
|
||||||
data: _.pick(credentials, "username", "password"),
|
|
||||||
}),
|
|
||||||
() => new AuthFailure("Failed to get bearerToken")
|
|
||||||
),
|
|
||||||
TE.map((it) => it.data.token as string | undefined)
|
|
||||||
),
|
|
||||||
artists: async (
|
|
||||||
q: ArtistQuery
|
|
||||||
): Promise<Result<ArtistSummary & Sortable>> => {
|
|
||||||
let params: any = {
|
|
||||||
_sort: "name",
|
|
||||||
_order: "ASC",
|
|
||||||
_start: q._index || "0",
|
|
||||||
};
|
|
||||||
if (q._count) {
|
|
||||||
params = {
|
|
||||||
...params,
|
|
||||||
_end: (q._index || 0) + q._count,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const x: Promise<Result<ArtistSummary & Sortable>> = axios
|
|
||||||
.get(`${url}/api/artist`, {
|
|
||||||
params: asURLSearchParams(params),
|
|
||||||
headers: {
|
|
||||||
"User-Agent": USER_AGENT,
|
|
||||||
"x-nd-authorization": `Bearer ${subsonicCredentials.bearer}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
throw `Navidrome failed with: ${e}`;
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (response.status != 200 && response.status != 206) {
|
|
||||||
throw `Navidrome failed with a ${response.status || "no!"} status`;
|
|
||||||
} else return response;
|
|
||||||
})
|
|
||||||
.then((it) => ({
|
|
||||||
results: (it.data as NDArtist[]).map(artistSummaryFromNDArtist),
|
|
||||||
total: Number.parseInt(it.headers["x-total-count"] || "0"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return x;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { isError, SubsonicEnvelope } from ".";
|
|
||||||
// todo: rename http2 to http
|
|
||||||
import { Http2, RequestParams } from "../http";
|
|
||||||
|
|
||||||
export type HttpResponse = {
|
|
||||||
data: any;
|
|
||||||
status: number;
|
|
||||||
headers: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
const asJSON = <T>(response: HttpResponse): T => {
|
|
||||||
const subsonicResponse = (response.data as SubsonicEnvelope)[
|
|
||||||
"subsonic-response"
|
|
||||||
];
|
|
||||||
if (isError(subsonicResponse))
|
|
||||||
throw `Subsonic error:${subsonicResponse.error.message}`;
|
|
||||||
else return subsonicResponse as unknown as T;
|
|
||||||
};
|
|
||||||
const throwUp = (error: any) => {
|
|
||||||
throw `Subsonic failed with: ${error}`;
|
|
||||||
};
|
|
||||||
const verifyResponse = (response: AxiosResponse<any>) => {
|
|
||||||
if (response.status != 200 && response.status != 206) {
|
|
||||||
throw `Subsonic failed with a ${response.status || "no!"} status`;
|
|
||||||
} else return response;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface SubsonicHttpResponse {
|
|
||||||
asRaw(): Promise<AxiosResponse<any>>;
|
|
||||||
asJSON<T>(): Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubsonicHttp {
|
|
||||||
(query: Partial<RequestParams>): SubsonicHttpResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const client = (http: Http2): SubsonicHttp => {
|
|
||||||
return (query: Partial<RequestParams>): SubsonicHttpResponse => {
|
|
||||||
return {
|
|
||||||
asRaw: () => http(query).catch(throwUp).then(verifyResponse),
|
|
||||||
|
|
||||||
asJSON: <T>() =>
|
|
||||||
http
|
|
||||||
.with({ params: { f: "json" } })(query)
|
|
||||||
.catch(throwUp)
|
|
||||||
.then(verifyResponse)
|
|
||||||
.then(asJSON) as Promise<T>,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
37
src/utils.ts
37
src/utils.ts
@@ -1,42 +1,7 @@
|
|||||||
import { flatten } from "underscore";
|
|
||||||
|
|
||||||
// todo: move this
|
|
||||||
export const BROWSER_HEADERS = {
|
|
||||||
accept:
|
|
||||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
|
||||||
"accept-encoding": "gzip, deflate, br",
|
|
||||||
"accept-language": "en-GB,en;q=0.5",
|
|
||||||
"upgrade-insecure-requests": "1",
|
|
||||||
"user-agent":
|
|
||||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0",
|
|
||||||
};
|
|
||||||
|
|
||||||
// todo: move this
|
|
||||||
export const asURLSearchParams = (q: any) => {
|
|
||||||
const urlSearchParams = new URLSearchParams();
|
|
||||||
Object.keys(q).forEach((k) => {
|
|
||||||
flatten([q[k]]).forEach((v) => {
|
|
||||||
urlSearchParams.append(k, `${v}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return urlSearchParams;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function takeWithRepeats<T>(things:T[], count: number) {
|
export function takeWithRepeats<T>(things:T[], count: number) {
|
||||||
const result = [];
|
const result = [];
|
||||||
for(let i = 0; i < count; i++) {
|
for(let i = 0; i < count; i++) {
|
||||||
result.push(things[i % things.length]);
|
result.push(things[i % things.length])
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mask = (thing: any, fields: string[]) =>
|
|
||||||
fields.reduce(
|
|
||||||
(res: any, key: string) => {
|
|
||||||
if (Object.keys(res).includes(key)) {
|
|
||||||
res[key] = "****";
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
{ ...thing }
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "../src/music_service";
|
} from "../src/music_service";
|
||||||
|
|
||||||
import { b64Encode } from "../src/b64";
|
import { b64Encode } from "../src/b64";
|
||||||
import { artistImageURN } from "../src/subsonic/generic";
|
import { artistImageURN } from "../src/subsonic";
|
||||||
|
|
||||||
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
|
const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max));
|
||||||
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
||||||
|
|||||||
@@ -271,6 +271,15 @@ describe("config", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("logRequests", () => {
|
||||||
|
describeBooleanConfigValue(
|
||||||
|
"logRequests",
|
||||||
|
"BNB_SERVER_LOG_REQUESTS",
|
||||||
|
false,
|
||||||
|
(config) => config.logRequests
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe("sonos", () => {
|
describe("sonos", () => {
|
||||||
describe("serviceName", () => {
|
describe("serviceName", () => {
|
||||||
it("should default to bonob", () => {
|
it("should default to bonob", () => {
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
import { http, http2From, } from "../src/http";
|
|
||||||
|
|
||||||
describe("http", () => {
|
|
||||||
const mockAxios = jest.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
["baseURL"],
|
|
||||||
["url"],
|
|
||||||
["method"],
|
|
||||||
])('%s', (field) => {
|
|
||||||
const getValue = (value: string) => {
|
|
||||||
const thing = {} as any;
|
|
||||||
thing[field] = value;
|
|
||||||
return thing;
|
|
||||||
};
|
|
||||||
|
|
||||||
const base = http(mockAxios, getValue('base'));
|
|
||||||
|
|
||||||
describe("using default", () => {
|
|
||||||
it("should use the default", () => {
|
|
||||||
base({})
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(getValue('base'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("overriding", () => {
|
|
||||||
it("should use the override", () => {
|
|
||||||
base(getValue('override'))
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(getValue('override'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("wrapping", () => {
|
|
||||||
const firstLayer = http(base, getValue('level1'));
|
|
||||||
const secondLayer = http(firstLayer, getValue('level2'));
|
|
||||||
|
|
||||||
describe("when the outter call provides a value", () => {
|
|
||||||
it("should apply it", () => {
|
|
||||||
secondLayer(getValue('outter'))
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(getValue('outter'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when the outter call does not provide a value", () => {
|
|
||||||
it("should use the second layer", () => {
|
|
||||||
secondLayer({ })
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(getValue('level2'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("requestType", () => {
|
|
||||||
const base = http(mockAxios, { responseType: 'stream' });
|
|
||||||
|
|
||||||
describe("using default", () => {
|
|
||||||
it("should use the default", () => {
|
|
||||||
base({})
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'stream' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("overriding", () => {
|
|
||||||
it("should use the override", () => {
|
|
||||||
base({ responseType: 'arraybuffer' })
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'arraybuffer' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("wrapping", () => {
|
|
||||||
const firstLayer = http(base, { responseType: 'arraybuffer' });
|
|
||||||
const secondLayer = http(firstLayer, { responseType: 'blob' });
|
|
||||||
|
|
||||||
describe("when the outter call provides a value", () => {
|
|
||||||
it("should apply it", () => {
|
|
||||||
secondLayer({ responseType: 'text' })
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'text' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when the outter call does not provide a value", () => {
|
|
||||||
it("should use the second layer", () => {
|
|
||||||
secondLayer({ })
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'blob' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
["params"],
|
|
||||||
["headers"],
|
|
||||||
])('%s', (field) => {
|
|
||||||
const getValues = (values: any) => {
|
|
||||||
const thing = {} as any;
|
|
||||||
thing[field] = values;
|
|
||||||
return thing;
|
|
||||||
}
|
|
||||||
const base = http(mockAxios, getValues({ a: 1, b: 2, c: 3, d: 4 }));
|
|
||||||
|
|
||||||
describe("using default", () => {
|
|
||||||
it("should use the default", () => {
|
|
||||||
base({});
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 1, b: 2, c: 3, d: 4 }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("overriding", () => {
|
|
||||||
it("should use the override", () => {
|
|
||||||
base(getValues({ b: 22, e: 5 }));
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 1, b: 22, c: 3, d: 4, e: 5 }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("wrapping", () => {
|
|
||||||
const firstLayer = http(base, getValues({ b: 22 }));
|
|
||||||
const secondLayer = http(firstLayer, getValues({ c: 33 }));
|
|
||||||
|
|
||||||
describe("when the outter call provides a value", () => {
|
|
||||||
it("should apply it", () => {
|
|
||||||
secondLayer(getValues({ a: 11, e: 5 }));
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 11, b: 22, c: 33, d: 4, e: 5 }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when the outter call does not provide a value", () => {
|
|
||||||
it("should use the second layer", () => {
|
|
||||||
secondLayer({ });
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(getValues({ a: 1, b: 22, c: 33, d: 4 }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("http2", () => {
|
|
||||||
const mockAxios = jest.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
["baseURL"],
|
|
||||||
["url"],
|
|
||||||
["method"],
|
|
||||||
])('%s', (field) => {
|
|
||||||
const fieldWithValue = (value: string) => {
|
|
||||||
const thing = {} as any;
|
|
||||||
thing[field] = value;
|
|
||||||
return thing;
|
|
||||||
};
|
|
||||||
|
|
||||||
const base = http2From(mockAxios).with(fieldWithValue('default'));
|
|
||||||
|
|
||||||
describe("using default", () => {
|
|
||||||
it("should use the default", () => {
|
|
||||||
base({})
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('default'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("overriding", () => {
|
|
||||||
it("should use the override", () => {
|
|
||||||
base(fieldWithValue('override'))
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('override'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("wrapping", () => {
|
|
||||||
const firstLayer = http2From(base).with(fieldWithValue('level1'));
|
|
||||||
const secondLayer = firstLayer.with(fieldWithValue('level2'));
|
|
||||||
|
|
||||||
describe("when the outter call provides a value", () => {
|
|
||||||
it("should apply it", () => {
|
|
||||||
secondLayer(fieldWithValue('outter'))
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('outter'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when the outter call does not provide a value", () => {
|
|
||||||
it("should use the second layer", () => {
|
|
||||||
secondLayer({ })
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('level2'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("requestType", () => {
|
|
||||||
const base = http2From(mockAxios).with({ responseType: 'stream' });
|
|
||||||
|
|
||||||
describe("using default", () => {
|
|
||||||
it("should use the default", () => {
|
|
||||||
base({})
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'stream' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("overriding", () => {
|
|
||||||
it("should use the override", () => {
|
|
||||||
base({ responseType: 'arraybuffer' })
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'arraybuffer' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("wrapping", () => {
|
|
||||||
const firstLayer = base.with({ responseType: 'arraybuffer' });
|
|
||||||
const secondLayer = firstLayer.with({ responseType: 'blob' });
|
|
||||||
|
|
||||||
describe("when the outter call provides a value", () => {
|
|
||||||
it("should apply it", () => {
|
|
||||||
secondLayer({ responseType: 'text' })
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'text' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when the outter call does not provide a value", () => {
|
|
||||||
it("should use the second layer", () => {
|
|
||||||
secondLayer({ })
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'blob' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
["params"],
|
|
||||||
["headers"],
|
|
||||||
])('%s', (field) => {
|
|
||||||
const fieldWithValues = (values: any) => {
|
|
||||||
const thing = {} as any;
|
|
||||||
thing[field] = values;
|
|
||||||
return thing;
|
|
||||||
}
|
|
||||||
const base = http2From(mockAxios).with(fieldWithValues({ a: 1, b: 2, c: 3, d: 4 }));
|
|
||||||
|
|
||||||
describe("using default", () => {
|
|
||||||
it("should use the default", () => {
|
|
||||||
base({});
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 2, c: 3, d: 4 }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("overriding", () => {
|
|
||||||
it("should use the override", () => {
|
|
||||||
base(fieldWithValues({ b: 22, e: 5 }));
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 22, c: 3, d: 4, e: 5 }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("wrapping", () => {
|
|
||||||
const firstLayer = base.with(fieldWithValues({ b: 22 }));
|
|
||||||
const secondLayer = firstLayer.with(fieldWithValues({ c: 33 }));
|
|
||||||
|
|
||||||
describe("when the outter call provides a value", () => {
|
|
||||||
it("should apply it", () => {
|
|
||||||
secondLayer(fieldWithValues({ a: 11, e: 5 }));
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 11, b: 22, c: 33, d: 4, e: 5 }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when the outter call does not provide a value", () => {
|
|
||||||
it("should use the second layer", () => {
|
|
||||||
secondLayer({ });
|
|
||||||
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 22, c: 33, d: 4 }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
});
|
|
||||||
@@ -34,7 +34,7 @@ describe("i8n", () => {
|
|||||||
|
|
||||||
describe("langs", () => {
|
describe("langs", () => {
|
||||||
it("should be all langs that are explicitly defined", () => {
|
it("should be all langs that are explicitly defined", () => {
|
||||||
expect(langs()).toEqual(["en-US", "nl-NL"]);
|
expect(langs()).toEqual(["en-US", "da-DK", "fr-FR", "nl-NL"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
|
|
||||||
import tmp from "tmp";
|
|
||||||
import fse from "fs-extra";
|
|
||||||
import path from "path";
|
|
||||||
import { Md5 } from "ts-md5";
|
|
||||||
|
|
||||||
import sharp from "sharp";
|
|
||||||
jest.mock("sharp");
|
|
||||||
|
|
||||||
import { cachingImageFetcher } from "../src/images";
|
|
||||||
|
|
||||||
describe("cachingImageFetcher", () => {
|
|
||||||
const delegate = jest.fn();
|
|
||||||
const url = "http://test.example.com/someimage.jpg";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when there is no image in the cache", () => {
|
|
||||||
it("should fetch the image from the source and then cache and return it", async () => {
|
|
||||||
const dir = tmp.dirSync();
|
|
||||||
const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`);
|
|
||||||
const jpgImage = Buffer.from("jpg-image", "utf-8");
|
|
||||||
const pngImage = Buffer.from("png-image", "utf-8");
|
|
||||||
|
|
||||||
delegate.mockResolvedValue({ contentType: "image/jpeg", data: jpgImage });
|
|
||||||
const png = jest.fn();
|
|
||||||
(sharp as unknown as jest.Mock).mockReturnValue({ png });
|
|
||||||
png.mockReturnValue({
|
|
||||||
toBuffer: () => Promise.resolve(pngImage),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await cachingImageFetcher(dir.name, delegate)(url);
|
|
||||||
|
|
||||||
expect(result!.contentType).toEqual("image/png");
|
|
||||||
expect(result!.data).toEqual(pngImage);
|
|
||||||
|
|
||||||
expect(delegate).toHaveBeenCalledWith(url);
|
|
||||||
expect(fse.existsSync(cacheFile)).toEqual(true);
|
|
||||||
expect(fse.readFileSync(cacheFile)).toEqual(pngImage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when the image is already in the cache", () => {
|
|
||||||
it("should fetch the image from the cache and return it", async () => {
|
|
||||||
const dir = tmp.dirSync();
|
|
||||||
const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`);
|
|
||||||
const data = Buffer.from("foobar2", "utf-8");
|
|
||||||
|
|
||||||
fse.writeFileSync(cacheFile, data);
|
|
||||||
|
|
||||||
const result = await cachingImageFetcher(dir.name, delegate)(url);
|
|
||||||
|
|
||||||
expect(result!.contentType).toEqual("image/png");
|
|
||||||
expect(result!.data).toEqual(data);
|
|
||||||
|
|
||||||
expect(delegate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when the delegate returns undefined", () => {
|
|
||||||
it("should return undefined", async () => {
|
|
||||||
const dir = tmp.dirSync();
|
|
||||||
const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`);
|
|
||||||
|
|
||||||
delegate.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
const result = await cachingImageFetcher(dir.name, delegate)(url);
|
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
|
|
||||||
expect(delegate).toHaveBeenCalledWith(url);
|
|
||||||
expect(fse.existsSync(cacheFile)).toEqual(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
MusicLibrary,
|
MusicLibrary,
|
||||||
artistToArtistSummary,
|
artistToArtistSummary,
|
||||||
albumToAlbumSummary,
|
albumToAlbumSummary,
|
||||||
Artist,
|
|
||||||
} from "../src/music_service";
|
} from "../src/music_service";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
import {
|
import {
|
||||||
@@ -79,11 +78,6 @@ describe("InMemoryMusicService", () => {
|
|||||||
musicLibrary = (await service.login(token.serviceToken)) as MusicLibrary;
|
musicLibrary = (await service.login(token.serviceToken)) as MusicLibrary;
|
||||||
});
|
});
|
||||||
|
|
||||||
const artistToArtistSummaryWithSortName = (artist: Artist) => ({
|
|
||||||
...artistToArtistSummary(artist),
|
|
||||||
sortName: artist.name
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("artists", () => {
|
describe("artists", () => {
|
||||||
const artist1 = anArtist();
|
const artist1 = anArtist();
|
||||||
const artist2 = anArtist();
|
const artist2 = anArtist();
|
||||||
@@ -101,11 +95,11 @@ describe("InMemoryMusicService", () => {
|
|||||||
await musicLibrary.artists({ _index: 0, _count: 100 })
|
await musicLibrary.artists({ _index: 0, _count: 100 })
|
||||||
).toEqual({
|
).toEqual({
|
||||||
results: [
|
results: [
|
||||||
artistToArtistSummaryWithSortName(artist1),
|
artistToArtistSummary(artist1),
|
||||||
artistToArtistSummaryWithSortName(artist2),
|
artistToArtistSummary(artist2),
|
||||||
artistToArtistSummaryWithSortName(artist3),
|
artistToArtistSummary(artist3),
|
||||||
artistToArtistSummaryWithSortName(artist4),
|
artistToArtistSummary(artist4),
|
||||||
artistToArtistSummaryWithSortName(artist5),
|
artistToArtistSummary(artist5),
|
||||||
],
|
],
|
||||||
total: 5,
|
total: 5,
|
||||||
});
|
});
|
||||||
@@ -116,8 +110,8 @@ describe("InMemoryMusicService", () => {
|
|||||||
it("should provide an array of artists", async () => {
|
it("should provide an array of artists", async () => {
|
||||||
expect(await musicLibrary.artists({ _index: 2, _count: 2 })).toEqual({
|
expect(await musicLibrary.artists({ _index: 2, _count: 2 })).toEqual({
|
||||||
results: [
|
results: [
|
||||||
artistToArtistSummaryWithSortName(artist3),
|
artistToArtistSummary(artist3),
|
||||||
artistToArtistSummaryWithSortName(artist4),
|
artistToArtistSummary(artist4),
|
||||||
],
|
],
|
||||||
total: 5,
|
total: 5,
|
||||||
});
|
});
|
||||||
@@ -127,7 +121,7 @@ describe("InMemoryMusicService", () => {
|
|||||||
describe("fetching the last page", () => {
|
describe("fetching the last page", () => {
|
||||||
it("should provide an array of artists", async () => {
|
it("should provide an array of artists", async () => {
|
||||||
expect(await musicLibrary.artists({ _index: 4, _count: 2 })).toEqual({
|
expect(await musicLibrary.artists({ _index: 4, _count: 2 })).toEqual({
|
||||||
results: [artistToArtistSummaryWithSortName(artist5)],
|
results: [artistToArtistSummary(artist5)],
|
||||||
total: 5,
|
total: 5,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
|
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
artists: (q: ArtistQuery) =>
|
artists: (q: ArtistQuery) =>
|
||||||
Promise.resolve(this.artists.map(artistToArtistSummary).map(it => ({ ...it, sortName: it.name })))
|
Promise.resolve(this.artists.map(artistToArtistSummary))
|
||||||
.then(slice2(q))
|
.then(slice2(q))
|
||||||
.then(asResult),
|
.then(asResult),
|
||||||
artist: (id: string) =>
|
artist: (id: string) =>
|
||||||
|
|||||||
@@ -1,57 +1,7 @@
|
|||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
import { anArtist } from "./builders";
|
import { anArtist } from "./builders";
|
||||||
import { artistToArtistSummary, slice2 } from "../src/music_service";
|
import { artistToArtistSummary } from "../src/music_service";
|
||||||
|
|
||||||
|
|
||||||
describe("slice2", () => {
|
|
||||||
const things = ["a", "b", "c", "d", "e", "f", "g", "h", "i"];
|
|
||||||
|
|
||||||
describe("when slice is a subset of the things", () => {
|
|
||||||
it("should return the page", () => {
|
|
||||||
expect(slice2({ _index: 3, _count: 4 })(things)).toEqual([
|
|
||||||
["d", "e", "f", "g"],
|
|
||||||
things.length
|
|
||||||
])
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when slice goes off the end of the things", () => {
|
|
||||||
it("should return the page", () => {
|
|
||||||
expect(slice2({ _index: 5, _count: 100 })(things)).toEqual([
|
|
||||||
["f", "g", "h", "i"],
|
|
||||||
things.length
|
|
||||||
])
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when no _count is provided", () => {
|
|
||||||
it("should return from the index", () => {
|
|
||||||
expect(slice2({ _index: 5 })(things)).toEqual([
|
|
||||||
["f", "g", "h", "i"],
|
|
||||||
things.length
|
|
||||||
])
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when no _index is provided", () => {
|
|
||||||
it("should assume from the start", () => {
|
|
||||||
expect(slice2({ _count: 3 })(things)).toEqual([
|
|
||||||
["a", "b", "c"],
|
|
||||||
things.length
|
|
||||||
])
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when no _index or _count is provided", () => {
|
|
||||||
it("should return all the things", () => {
|
|
||||||
expect(slice2()(things)).toEqual([
|
|
||||||
things,
|
|
||||||
things.length
|
|
||||||
])
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("artistToArtistSummary", () => {
|
describe("artistToArtistSummary", () => {
|
||||||
it("should map fields correctly", () => {
|
it("should map fields correctly", () => {
|
||||||
|
|||||||
@@ -167,15 +167,13 @@ describe("RangeBytesFromFilter", () => {
|
|||||||
|
|
||||||
|
|
||||||
describe("server", () => {
|
describe("server", () => {
|
||||||
jest.setTimeout(Number.parseInt(process.env["JEST_TIMEOUT"] || "5000"));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
jest.resetAllMocks();
|
jest.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const bonobUrlWithNoContextPath = url("http://bonob.localhost:1234");
|
const bonobUrlWithNoContextPath = url("http://localhost:1234");
|
||||||
const bonobUrlWithContextPath = url("http://bonob.localhost:1234/aContext");
|
const bonobUrlWithContextPath = url("http://localhost:1234/aContext");
|
||||||
|
|
||||||
const langName = randomLang();
|
const langName = randomLang();
|
||||||
const acceptLanguage = `le-ET,${langName};q=0.9,en;q=0.8`;
|
const acceptLanguage = `le-ET,${langName};q=0.9,en;q=0.8`;
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
sonosifyMimeType,
|
sonosifyMimeType,
|
||||||
ratingAsInt,
|
ratingAsInt,
|
||||||
ratingFromInt,
|
ratingFromInt,
|
||||||
scrollIndicesFrom,
|
|
||||||
} from "../src/smapi";
|
} from "../src/smapi";
|
||||||
|
|
||||||
import { keys as i8nKeys } from "../src/i8n";
|
import { keys as i8nKeys } from "../src/i8n";
|
||||||
@@ -57,7 +56,7 @@ import dayjs from "dayjs";
|
|||||||
import url, { URLBuilder } from "../src/url_builder";
|
import url, { URLBuilder } from "../src/url_builder";
|
||||||
import { iconForGenre } from "../src/icon";
|
import { iconForGenre } from "../src/icon";
|
||||||
import { formatForURL } from "../src/burn";
|
import { formatForURL } from "../src/burn";
|
||||||
import _, { range } from "underscore";
|
import { range } from "underscore";
|
||||||
import { FixedClock } from "../src/clock";
|
import { FixedClock } from "../src/clock";
|
||||||
import { ExpiredTokenError, InvalidTokenError, SmapiAuthTokens, SmapiToken, ToSmapiFault } from "../src/smapi_auth";
|
import { ExpiredTokenError, InvalidTokenError, SmapiAuthTokens, SmapiToken, ToSmapiFault } from "../src/smapi_auth";
|
||||||
|
|
||||||
@@ -91,8 +90,6 @@ describe("rating to and from ints", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("service config", () => {
|
describe("service config", () => {
|
||||||
jest.setTimeout(Number.parseInt(process.env["JEST_TIMEOUT"] || "5000"));
|
|
||||||
|
|
||||||
const bonobWithNoContextPath = url("http://localhost:1234");
|
const bonobWithNoContextPath = url("http://localhost:1234");
|
||||||
const bonobWithContextPath = url("http://localhost:5678/some-context-path");
|
const bonobWithContextPath = url("http://localhost:5678/some-context-path");
|
||||||
|
|
||||||
@@ -123,7 +120,7 @@ describe("service config", () => {
|
|||||||
|
|
||||||
describe(STRINGS_ROUTE, () => {
|
describe(STRINGS_ROUTE, () => {
|
||||||
it("should return xml for the strings", async () => {
|
it("should return xml for the strings", async () => {
|
||||||
const xml = await fetchStringsXml();
|
const xml: Document = await fetchStringsXml();
|
||||||
|
|
||||||
const sonosString = (id: string, lang: string) =>
|
const sonosString = (id: string, lang: string) =>
|
||||||
xpath.select(
|
xpath.select(
|
||||||
@@ -138,8 +135,8 @@ describe("service config", () => {
|
|||||||
"Sonos koppelen aan music land"
|
"Sonos koppelen aan music land"
|
||||||
);
|
);
|
||||||
|
|
||||||
// no fr-FR translation, so use en-US
|
// no pt-BR translation, so use en-US
|
||||||
expect(sonosString("AppLinkMessage", "fr-FR")).toEqual(
|
expect(sonosString("AppLinkMessage", "pt-BR")).toEqual(
|
||||||
"Linking sonos with music land"
|
"Linking sonos with music land"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -522,7 +519,6 @@ describe("playlistAlbumArtURL", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("when the playlist has external ids", () => {
|
describe("when the playlist has external ids", () => {
|
||||||
it("should format the url with encrypted urn", () => {
|
|
||||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||||
const externalArt1 = {
|
const externalArt1 = {
|
||||||
system: "external",
|
system: "external",
|
||||||
@@ -546,6 +542,7 @@ describe("playlistAlbumArtURL", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should format the url with encrypted urn", () => {
|
||||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
`http://localhost:1234/context-path/art/${encodeURIComponent(
|
`http://localhost:1234/context-path/art/${encodeURIComponent(
|
||||||
formatForURL(externalArt1)
|
formatForURL(externalArt1)
|
||||||
@@ -554,6 +551,26 @@ describe("playlistAlbumArtURL", () => {
|
|||||||
)}/size/180?search=yes`
|
)}/size/180?search=yes`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("when BNB_NO_PLAYLIST_ART is set", () => {
|
||||||
|
const OLD_ENV = process.env;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env = { ...OLD_ENV };
|
||||||
|
|
||||||
|
process.env["BNB_DISABLE_PLAYLIST_ART"] = "true";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = OLD_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return an icon", () => {
|
||||||
|
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||||
|
`http://localhost:1234/context-path/icon/music/size/legacy?search=yes`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when the playlist has 4 tracks from 2 different albums, including some tracks that are missing coverArt urns", () => {
|
describe("when the playlist has 4 tracks from 2 different albums, including some tracks that are missing coverArt urns", () => {
|
||||||
@@ -861,54 +878,6 @@ describe("defaultArtistArtURI", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("scrollIndicesFrom", () => {
|
|
||||||
describe("artists", () => {
|
|
||||||
describe("when sortName is the same as name", () => {
|
|
||||||
it("should be scroll indicies", () => {
|
|
||||||
const artistNames = [
|
|
||||||
"10,000 Maniacs",
|
|
||||||
"99 Bacon Sandwiches",
|
|
||||||
"[something with square brackets]",
|
|
||||||
"Aerosmith",
|
|
||||||
"Bob Marley",
|
|
||||||
"beatles", // intentionally lower case
|
|
||||||
"Cans",
|
|
||||||
"egg heads", // intentionally lower case
|
|
||||||
"Moon Cakes",
|
|
||||||
"Moon Boots",
|
|
||||||
"Numpty",
|
|
||||||
"Yellow brick road"
|
|
||||||
]
|
|
||||||
const scrollIndicies = scrollIndicesFrom(artistNames.map(name => ({ name, sortName: name })))
|
|
||||||
|
|
||||||
expect(scrollIndicies).toEqual("A,3,B,4,C,6,D,6,E,7,F,7,G,7,H,7,I,7,J,7,K,7,L,7,M,8,N,10,O,10,P,10,Q,10,R,10,S,10,T,10,U,10,V,10,W,10,X,10,Y,11,Z,11")
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when sortName is different to the name name", () => {
|
|
||||||
it("should be scroll indicies", () => {
|
|
||||||
const artistSortNames = [
|
|
||||||
"10,000 Maniacs",
|
|
||||||
"99 Bacon Sandwiches",
|
|
||||||
"[something with square brackets]",
|
|
||||||
"Aerosmith",
|
|
||||||
"Bob Marley",
|
|
||||||
"beatles", // intentionally lower case
|
|
||||||
"Cans",
|
|
||||||
"egg heads", // intentionally lower case
|
|
||||||
"Moon Cakes",
|
|
||||||
"Moon Boots",
|
|
||||||
"Numpty",
|
|
||||||
"Yellow brick road"
|
|
||||||
]
|
|
||||||
const scrollIndicies = scrollIndicesFrom(artistSortNames.map(name => ({ name: uuid(), sortName: name })))
|
|
||||||
|
|
||||||
expect(scrollIndicies).toEqual("A,3,B,4,C,6,D,6,E,7,F,7,G,7,H,7,I,7,J,7,K,7,L,7,M,8,N,10,O,10,P,10,Q,10,R,10,S,10,T,10,U,10,V,10,W,10,X,10,Y,11,Z,11")
|
|
||||||
});
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("wsdl api", () => {
|
describe("wsdl api", () => {
|
||||||
const musicService = {
|
const musicService = {
|
||||||
generateToken: jest.fn(),
|
generateToken: jest.fn(),
|
||||||
@@ -1459,7 +1428,6 @@ describe("wsdl api", () => {
|
|||||||
title: "Artists",
|
title: "Artists",
|
||||||
albumArtURI: iconArtURI(bonobUrl, "artists").href(),
|
albumArtURI: iconArtURI(bonobUrl, "artists").href(),
|
||||||
itemType: "container",
|
itemType: "container",
|
||||||
canScroll: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "albums",
|
id: "albums",
|
||||||
@@ -1548,7 +1516,6 @@ describe("wsdl api", () => {
|
|||||||
title: "Artiesten",
|
title: "Artiesten",
|
||||||
albumArtURI: iconArtURI(bonobUrl, "artists").href(),
|
albumArtURI: iconArtURI(bonobUrl, "artists").href(),
|
||||||
itemType: "container",
|
itemType: "container",
|
||||||
canScroll: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "albums",
|
id: "albums",
|
||||||
@@ -1704,10 +1671,10 @@ describe("wsdl api", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("asking for playlists", () => {
|
describe("asking for playlists", () => {
|
||||||
const playlist1 = aPlaylist({ id: "1", name: "pl1" });
|
const playlist1 = aPlaylist({ id: "1", name: "pl1", entries: []});
|
||||||
const playlist2 = aPlaylist({ id: "2", name: "pl2" });
|
const playlist2 = aPlaylist({ id: "2", name: "pl2", entries: []});
|
||||||
const playlist3 = aPlaylist({ id: "3", name: "pl3" });
|
const playlist3 = aPlaylist({ id: "3", name: "pl3", entries: []});
|
||||||
const playlist4 = aPlaylist({ id: "4", name: "pl4" });
|
const playlist4 = aPlaylist({ id: "4", name: "pl4", entries: []});
|
||||||
|
|
||||||
const playlists = [playlist1, playlist2, playlist3, playlist4];
|
const playlists = [playlist1, playlist2, playlist3, playlist4];
|
||||||
|
|
||||||
@@ -3162,51 +3129,6 @@ describe("wsdl api", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getScrollIndices", () => {
|
|
||||||
itShouldHandleInvalidCredentials((ws) =>
|
|
||||||
ws.getScrollIndicesAsync({ id: `artists` })
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("for artists", () => {
|
|
||||||
let ws: Client;
|
|
||||||
|
|
||||||
const artist1 = anArtist({ name: "Aerosmith" });
|
|
||||||
const artist2 = anArtist({ name: "Bob Marley" });
|
|
||||||
const artist3 = anArtist({ name: "Beatles" });
|
|
||||||
const artist4 = anArtist({ name: "Cat Empire" });
|
|
||||||
const artist5 = anArtist({ name: "Metallica" });
|
|
||||||
const artist6 = anArtist({ name: "Yellow Brick Road" });
|
|
||||||
|
|
||||||
const artists = [artist1, artist2, artist3, artist4, artist5, artist6];
|
|
||||||
const artistsWithSortName = artists.map(it => ({ ...it, sortName: it.name }));
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
ws = await createClientAsync(`${service.uri}?wsdl`, {
|
|
||||||
endpoint: service.uri,
|
|
||||||
httpClient: supersoap(server),
|
|
||||||
});
|
|
||||||
setupAuthenticatedRequest(ws);
|
|
||||||
musicLibrary.artists.mockResolvedValue({
|
|
||||||
results: artistsWithSortName,
|
|
||||||
total: 6
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return paging information", async () => {
|
|
||||||
const root = await ws.getScrollIndicesAsync({
|
|
||||||
id: `artists`,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(root[0]).toEqual({
|
|
||||||
getScrollIndicesResult: scrollIndicesFrom(artistsWithSortName)
|
|
||||||
});
|
|
||||||
expect(musicService.login).toHaveBeenCalledWith(serviceToken);
|
|
||||||
expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken);
|
|
||||||
expect(musicLibrary.artists).toHaveBeenCalledWith({ _index: 0, _count: undefined });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("createContainer", () => {
|
describe("createContainer", () => {
|
||||||
let ws: Client;
|
let ws: Client;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,78 +0,0 @@
|
|||||||
import { v4 as uuid } from "uuid";
|
|
||||||
import { DODGY_IMAGE_NAME } from "../../src/subsonic";
|
|
||||||
import { artistImageURN } from "../../src/subsonic/generic";
|
|
||||||
import { artistSummaryFromNDArtist } from "../../src/subsonic/navidrome";
|
|
||||||
|
|
||||||
|
|
||||||
describe("artistSummaryFromNDArtist", () => {
|
|
||||||
describe("when the orderArtistName is undefined", () => {
|
|
||||||
it("should use name", () => {
|
|
||||||
const artist = {
|
|
||||||
id: uuid(),
|
|
||||||
name: `name ${uuid()}`,
|
|
||||||
orderArtistName: undefined,
|
|
||||||
largeImageUrl: 'http://example.com/something.jpg'
|
|
||||||
}
|
|
||||||
expect(artistSummaryFromNDArtist(artist)).toEqual({
|
|
||||||
id: artist.id,
|
|
||||||
name: artist.name,
|
|
||||||
sortName: artist.name,
|
|
||||||
image: artistImageURN({ artistId: artist.id, artistImageURL: artist.largeImageUrl })
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when the artist image is valid", () => {
|
|
||||||
it("should create an ArtistSummary with Sortable", () => {
|
|
||||||
const artist = {
|
|
||||||
id: uuid(),
|
|
||||||
name: `name ${uuid()}`,
|
|
||||||
orderArtistName: `orderArtistName ${uuid()}`,
|
|
||||||
largeImageUrl: 'http://example.com/something.jpg'
|
|
||||||
}
|
|
||||||
expect(artistSummaryFromNDArtist(artist)).toEqual({
|
|
||||||
id: artist.id,
|
|
||||||
name: artist.name,
|
|
||||||
sortName: artist.orderArtistName,
|
|
||||||
image: artistImageURN({ artistId: artist.id, artistImageURL: artist.largeImageUrl })
|
|
||||||
})
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when the artist image is not valid", () => {
|
|
||||||
it("should create an ArtistSummary with Sortable", () => {
|
|
||||||
const artist = {
|
|
||||||
id: uuid(),
|
|
||||||
name: `name ${uuid()}`,
|
|
||||||
orderArtistName: `orderArtistName ${uuid()}`,
|
|
||||||
largeImageUrl: `http://example.com/${DODGY_IMAGE_NAME}`
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(artistSummaryFromNDArtist(artist)).toEqual({
|
|
||||||
id: artist.id,
|
|
||||||
name: artist.name,
|
|
||||||
sortName: artist.orderArtistName,
|
|
||||||
image: artistImageURN({ artistId: artist.id, artistImageURL: artist.largeImageUrl })
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("when the artist image is missing", () => {
|
|
||||||
it("should create an ArtistSummary with Sortable", () => {
|
|
||||||
const artist = {
|
|
||||||
id: uuid(),
|
|
||||||
name: `name ${uuid()}`,
|
|
||||||
orderArtistName: `orderArtistName ${uuid()}`,
|
|
||||||
largeImageUrl: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(artistSummaryFromNDArtist(artist)).toEqual({
|
|
||||||
id: artist.id,
|
|
||||||
name: artist.name,
|
|
||||||
sortName: artist.orderArtistName,
|
|
||||||
image: artistImageURN({ artistId: artist.id, artistImageURL: artist.largeImageUrl })
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,50 +1,4 @@
|
|||||||
import { asURLSearchParams, mask, takeWithRepeats } from "../src/utils";
|
import { takeWithRepeats } from "../src/utils";
|
||||||
|
|
||||||
describe("asURLSearchParams", () => {
|
|
||||||
describe("empty q", () => {
|
|
||||||
it("should return empty params", () => {
|
|
||||||
const q = {};
|
|
||||||
const expected = new URLSearchParams();
|
|
||||||
expect(asURLSearchParams(q)).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("singular params", () => {
|
|
||||||
it("should append each", () => {
|
|
||||||
const q = {
|
|
||||||
a: 1,
|
|
||||||
b: "bee",
|
|
||||||
c: false,
|
|
||||||
d: true,
|
|
||||||
};
|
|
||||||
const expected = new URLSearchParams();
|
|
||||||
expected.append("a", "1");
|
|
||||||
expected.append("b", "bee");
|
|
||||||
expected.append("c", "false");
|
|
||||||
expected.append("d", "true");
|
|
||||||
|
|
||||||
expect(asURLSearchParams(q)).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("list params", () => {
|
|
||||||
it("should append each", () => {
|
|
||||||
const q = {
|
|
||||||
a: [1, "two", false, true],
|
|
||||||
b: "yippee",
|
|
||||||
};
|
|
||||||
|
|
||||||
const expected = new URLSearchParams();
|
|
||||||
expected.append("a", "1");
|
|
||||||
expected.append("a", "two");
|
|
||||||
expected.append("a", "false");
|
|
||||||
expected.append("a", "true");
|
|
||||||
expected.append("b", "yippee");
|
|
||||||
|
|
||||||
expect(asURLSearchParams(q)).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("takeWithRepeat", () => {
|
describe("takeWithRepeat", () => {
|
||||||
describe("when there is nothing in the input", () => {
|
describe("when there is nothing in the input", () => {
|
||||||
@@ -75,32 +29,7 @@ describe("takeWithRepeat", () => {
|
|||||||
describe("when there more than the amount required", () => {
|
describe("when there more than the amount required", () => {
|
||||||
it("should return the first n items", () => {
|
it("should return the first n items", () => {
|
||||||
expect(takeWithRepeats(["a", "b", "c"], 2)).toEqual(["a", "b"]);
|
expect(takeWithRepeats(["a", "b", "c"], 2)).toEqual(["a", "b"]);
|
||||||
expect(takeWithRepeats(["a", undefined, "c"], 2)).toEqual([
|
expect(takeWithRepeats(["a", undefined, "c"], 2)).toEqual(["a", undefined]);
|
||||||
"a",
|
|
||||||
undefined,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mask", () => {
|
|
||||||
it.each([
|
|
||||||
[{}, ["a", "b"], {}],
|
|
||||||
[{ foo: "bar" }, ["a", "b"], { foo: "bar" }],
|
|
||||||
[{ a: 1 }, ["a", "b"], { a: "****" }],
|
|
||||||
[{ a: 1, b: "dog" }, ["a", "b"], { a: "****", b: "****" }],
|
|
||||||
[
|
|
||||||
{ a: 1, b: "dog", foo: "bar" },
|
|
||||||
["a", "b"],
|
|
||||||
{ a: "****", b: "****", foo: "bar" },
|
|
||||||
],
|
|
||||||
])(
|
|
||||||
"masking of %s, keys = %s, should result in %s",
|
|
||||||
(original: any, keys: string[], expected: any) => {
|
|
||||||
const copyOfOrig = JSON.parse(JSON.stringify(original));
|
|
||||||
const masked = mask(original, keys);
|
|
||||||
expect(masked).toEqual(expected);
|
|
||||||
expect(original).toEqual(copyOfOrig);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user