mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Compare commits
11 Commits
v0.7.0
...
feature/v6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1aa846c27 | ||
|
|
698a85b045 | ||
|
|
14dc060aac | ||
|
|
fef1013777 | ||
|
|
2681710b31 | ||
|
|
91675340e5 | ||
|
|
0488f398c1 | ||
|
|
e7f5f5871e | ||
|
|
eb3124b705 | ||
|
|
4b7be66385 | ||
|
|
212f6e34dc |
@@ -20,8 +20,9 @@
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
"esbenp.prettier-vscode",
|
||||
"redhat.vscode-xml"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -40,10 +40,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -69,10 +69,12 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Push image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# linux/arm/v6,
|
||||
|
||||
|
||||
35
README.md
35
README.md
@@ -9,14 +9,14 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
|
||||
## Features
|
||||
|
||||
- Integrates with Subsonic API clones (Navidrome, Gonic)
|
||||
- Browse by Artist, Albums, Random, Favourites, Top Rated, Playlist, Genres, Recently Added Albums, Recently Played Albums, Most Played Albums
|
||||
- Browse by Artist, Albums, Random, Favourites, Top Rated, Playlist, Genres, Years, Recently Added Albums, Recently Played Albums, Most Played Albums
|
||||
- Artist & Album Art
|
||||
- View Related Artists via Artist -> '...' -> Menu -> Related Arists
|
||||
- Now playing & Track Scrobbling
|
||||
- Search by Album, Artist, Track
|
||||
- Playlist editing through sonos app.
|
||||
- Marking of songs as favourites and with ratings through the sonos app.
|
||||
- Localization (only en-US, da-DK & nl-NL supported currently, require translations for other languages). [Sonos localization and supported languages](https://docs.sonos.com/docs/localization)
|
||||
- Localization (only en-US, da-DK, nl-NL & fr-FR supported currently, require translations for other languages). [Sonos localization and supported languages](https://docs.sonos.com/docs/localization)
|
||||
- Auto discovery of sonos devices
|
||||
- Discovery of sonos devices using seed IP address
|
||||
- Auto registration with sonos on start
|
||||
@@ -40,8 +40,8 @@ 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
|
||||
master | Lastest build from master, probably works, however is currently under test
|
||||
vX.Y.Z | Fixed release versions from tags, for those that want to pin to a specific release
|
||||
|
||||
|
||||
### Full sonos device auto-discovery and auto-registration using docker --network host
|
||||
@@ -171,7 +171,7 @@ BNB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or ommit
|
||||
BNB_SONOS_SERVICE_NAME | bonob | service name for sonos
|
||||
BNB_SONOS_SERVICE_ID | 246 | service id for sonos
|
||||
BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone
|
||||
BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. Must specify by the source mime type and the transcoded mime type. For example; <p>If you want to simply re-encode some flacs, then you could specify just "audio/flac". <p>However; if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3" <p>If you want to specify many something like; "audio/flac>audio/mp3,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs. <p>!!! Getting this configuration wrong will confuse SONOS as it will expect the wrong mime type for a track, as a result it will not play. Use with care...
|
||||
BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. <P>Must specify the source mime type and optionally the transcoded mime type. <p>For example; <p>If you want to simply re-encode some flacs, then you could specify just "audio/flac". <p>However; <p>if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3" <p>If you want to specify many something like; "audio/flac>audio/mp3,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs. <p>Disclaimer: Getting this configuration wrong will cause sonos to refuse to play your music, by all means experiment, however know that this may well break your setup.
|
||||
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_REPORT_NOW_PLAYING | true | Whether to report a track as now playing
|
||||
@@ -218,13 +218,22 @@ Afterwards the Sonos app displays a dropdown underneath the service, allowing to
|
||||
- Implement the MusicService/MusicLibrary interface
|
||||
- Startup bonob with your new implementation.
|
||||
|
||||
### Audio File type specific transcoding options within Subsonic
|
||||
## Transcoding
|
||||
|
||||
In some situations you may wish to have different 'Players' within you Subsonic server so that you can configure different transcoding options depending on the file type. For example if you have flacs with a mixture of frequency formats where not all are supported by sonos [See issue #52](https://github.com/simojenki/bonob/issues/52) & [Sonos supported audio formats](https://docs.sonos.com/docs/supported-audio-formats)
|
||||
### Transcode everything
|
||||
|
||||
The simplest transcoding solution is to simply change the player ('bonob') in your subsonic server to transcode all content to something sonos supports (ie. mp3 & flac)
|
||||
|
||||
### Audio file type specific transcoding
|
||||
|
||||
Disclaimer: The following configuration is more complicated, and if you get the configuration wrong sonos will refuse to play your content.
|
||||
|
||||
In some situations you may wish to have different 'Players' within your Subsonic server so that you can configure different transcoding options depending on the file type. For example if you have flacs with a mixture of frequency formats where not all are supported by sonos [See issue #52](https://github.com/simojenki/bonob/issues/52) & [Sonos supported audio formats](https://docs.sonos.com/docs/supported-audio-formats)
|
||||
|
||||
In this case you could set;
|
||||
|
||||
```bash
|
||||
# This is equivalent to setting BNB_SUBSONIC_CUSTOM_CLIENTS="audio/flac>audio/flac"
|
||||
BNB_SUBSONIC_CUSTOM_CLIENTS="audio/flac"
|
||||
```
|
||||
|
||||
@@ -240,7 +249,16 @@ ffmpeg -i %s -af aformat=sample_fmts=s16|s32:sample_rates=8000|11025|16000|22050
|
||||
ffmpeg -i %s -af aformat=sample_fmts=s16:sample_rates=8000|11025|16000|22050|24000|32000|44100|48000 -f flac -
|
||||
```
|
||||
|
||||
### Changing Icon colors
|
||||
Alternatively perhaps you have some aac (audio/mpeg) files that will not play in sonos (ie. voice recordings from an iPhone), however you do not want to transcode all everything, just those audio/mpeg files. Let's say you want to transcode them to mp3s, you could set the following;
|
||||
|
||||
```bash
|
||||
BNB_SUBSONIC_CUSTOM_CLIENTS="audio/mpeg>audio/mp3"
|
||||
```
|
||||
|
||||
And then configure the 'bonob+audio/mpeg' player in your subsonic server.
|
||||
|
||||
|
||||
## Changing Icon colors
|
||||
|
||||
```bash
|
||||
-e BNB_ICON_FOREGROUND_COLOR=white \
|
||||
@@ -270,6 +288,7 @@ ffmpeg -i %s -af aformat=sample_fmts=s16:sample_rates=8000|11025|16000|22050|240
|
||||
|
||||

|
||||
|
||||
|
||||
## Credits
|
||||
|
||||
- Icons courtesy of: [Navidrome](https://www.navidrome.org/), [Vectornator](https://www.vectornator.io/icons), and @jicho
|
||||
|
||||
130
package-lock.json
generated
130
package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@svrooij/sonos": "^2.5.0",
|
||||
"@svrooij/sonos": "^2.6.0-beta.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
@@ -1611,6 +1611,14 @@
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
|
||||
},
|
||||
"node_modules/@rgrove/parse-xml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rgrove/parse-xml/-/parse-xml-4.1.0.tgz",
|
||||
"integrity": "sha512-pBiltENdy8SfI0AeR1e5TRpS9/9Gl0eiOEt6ful2jQfzsgvZYWqsKiBWaOCLdocQuk0wS7KOHI37n0C1pnKqTw==",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.8",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||
@@ -1636,31 +1644,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@svrooij/sonos": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@svrooij/sonos/-/sonos-2.5.0.tgz",
|
||||
"integrity": "sha512-QcRJRo9aILj5/6ikebQsGZL9kqGP9Er6XyWJG5akHI0XIALMk/kLyXVrAnYZ2jef3Hj2GCCHCqCNkbAIqCZ53Q==",
|
||||
"version": "2.6.0-beta.7",
|
||||
"resolved": "https://registry.npmjs.org/@svrooij/sonos/-/sonos-2.6.0-beta.7.tgz",
|
||||
"integrity": "sha512-A9kmCVcIRfS0xksfirmFHnQbFwEkhtJbNKdmgszLeISJD4ugXLRWWGNPxI7HWXV9c8Sbkb/lNa0ZHdIX3zxrMQ==",
|
||||
"dependencies": {
|
||||
"debug": "4.3.1",
|
||||
"fast-xml-parser": "3.19.0",
|
||||
"guid-typescript": "^1.0.9",
|
||||
"html-entities": "^2.3.2",
|
||||
"node-fetch": "^2.6.1",
|
||||
"typed-emitter": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@svrooij/sonos/node_modules/fast-xml-parser": {
|
||||
"version": "3.21.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.21.1.tgz",
|
||||
"integrity": "sha512-FTFVjYoBOZTJekiUsawGsSYV9QL0A+zDYCRj7y34IO6Jg+2IMYEtQa+bbictpdpV8dHxXywqU7C0gRDEOFtBFg==",
|
||||
"dependencies": {
|
||||
"strnum": "^1.0.4"
|
||||
},
|
||||
"bin": {
|
||||
"xml2js": "cli.js"
|
||||
},
|
||||
"funding": {
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/naturalintelligence"
|
||||
"@rgrove/parse-xml": "^4.1.0",
|
||||
"debug": "4.3.4",
|
||||
"html-entities": "^2.4.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"ws": "^8.12.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@swiftcarrot/color-fns": {
|
||||
@@ -2901,9 +2894,9 @@
|
||||
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
|
||||
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
@@ -3764,11 +3757,6 @@
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
|
||||
},
|
||||
"node_modules/guid-typescript": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
|
||||
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -6358,6 +6346,15 @@
|
||||
"integrity": "sha512-AvLExwpaqUqD1uwLU6MwzzfRdaI6VEZsyvQ3IAQ0ZJ08v1H+DTyqskrf2ZJyh0BDduFVLN7H04Zmc+qTiahhAw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
|
||||
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -6688,22 +6685,6 @@
|
||||
"axios": "^0.27.2"
|
||||
}
|
||||
},
|
||||
"node_modules/soap/node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/soap/node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
@@ -6848,11 +6829,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz",
|
||||
"integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA=="
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz",
|
||||
@@ -6874,23 +6850,6 @@
|
||||
"node": ">=6.4.0 <13 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
|
||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/formidable": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.2.tgz",
|
||||
@@ -7313,9 +7272,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typed-emitter": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.4.0.tgz",
|
||||
"integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg=="
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||
"optionalDependencies": {
|
||||
"rxjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.3.3",
|
||||
@@ -7578,6 +7540,26 @@
|
||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
|
||||
"integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-crypto": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-3.2.0.tgz",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"author": "simojenki <simojenki@users.noreply.github.com>",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@svrooij/sonos": "^2.5.0",
|
||||
"@svrooij/sonos": "^2.6.0-beta.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
@@ -60,10 +60,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"axios-ntlm": "npm:dry-uninstall",
|
||||
"axios": "$axios",
|
||||
"@svrooij/sonos": {
|
||||
"fast-xml-parser": "^3.21.1"
|
||||
}
|
||||
"axios": "$axios"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -Rf build node_modules",
|
||||
|
||||
10
src/i8n.ts
10
src/i8n.ts
@@ -9,6 +9,7 @@ export type KEY =
|
||||
| "AppLinkMessage"
|
||||
| "artists"
|
||||
| "albums"
|
||||
| "internetRadio"
|
||||
| "playlists"
|
||||
| "genres"
|
||||
| "random"
|
||||
@@ -39,6 +40,7 @@ export type KEY =
|
||||
| "loginFailed"
|
||||
| "noSonosDevices"
|
||||
| "favourites"
|
||||
| "years"
|
||||
| "LOVE"
|
||||
| "LOVE_SUCCESS"
|
||||
| "STAR"
|
||||
@@ -51,6 +53,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
AppLinkMessage: "Linking sonos with $BNB_SONOS_SERVICE_NAME",
|
||||
artists: "Artists",
|
||||
albums: "Albums",
|
||||
internetRadio: "Internet Radio",
|
||||
tracks: "Tracks",
|
||||
playlists: "Playlists",
|
||||
genres: "Genres",
|
||||
@@ -81,6 +84,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "Login failed!",
|
||||
noSonosDevices: "No sonos devices",
|
||||
favourites: "Favourites",
|
||||
years: "Years",
|
||||
STAR: "Star",
|
||||
UNSTAR: "Un-star",
|
||||
STAR_SUCCESS: "Track starred",
|
||||
@@ -92,6 +96,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
AppLinkMessage: "Forbinder Sonos med $BNB_SONOS_SERVICE_NAME",
|
||||
artists: "Kunstnere",
|
||||
albums: "Album",
|
||||
internetRadio: "Internet Radio",
|
||||
tracks: "Numre",
|
||||
playlists: "Afspilningslister",
|
||||
genres: "Genre",
|
||||
@@ -122,6 +127,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "Log på fejlede!",
|
||||
noSonosDevices: "Ingen Sonos enheder",
|
||||
favourites: "Favoritter",
|
||||
years: "Flere år",
|
||||
STAR: "Tilføj stjerne",
|
||||
UNSTAR: "Fjern stjerne",
|
||||
STAR_SUCCESS: "Stjerne tilføjet",
|
||||
@@ -133,6 +139,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
AppLinkMessage: "Associer Sonos à $BNB_SONOS_SERVICE_NAME",
|
||||
artists: "Artistes",
|
||||
albums: "Albums",
|
||||
internetRadio: "Radio Internet",
|
||||
tracks: "Pistes",
|
||||
playlists: "Playlists",
|
||||
genres: "Genres",
|
||||
@@ -163,6 +170,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "La connexion a échoué !",
|
||||
noSonosDevices: "Aucun appareil Sonos",
|
||||
favourites: "Favoris",
|
||||
years: "Années",
|
||||
STAR: "Suivre",
|
||||
UNSTAR: "Ne plus suivre",
|
||||
STAR_SUCCESS: "Piste suivie",
|
||||
@@ -174,6 +182,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
AppLinkMessage: "Sonos koppelen aan $BNB_SONOS_SERVICE_NAME",
|
||||
artists: "Artiesten",
|
||||
albums: "Albums",
|
||||
internetRadio: "Internet Radio",
|
||||
tracks: "Nummers",
|
||||
playlists: "Afspeellijsten",
|
||||
genres: "Genres",
|
||||
@@ -204,6 +213,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
loginFailed: "Inloggen mislukt!",
|
||||
noSonosDevices: "Geen Sonos-apparaten",
|
||||
favourites: "Favorieten",
|
||||
years: "Jaren",
|
||||
STAR: "Ster ",
|
||||
UNSTAR: "Een ster",
|
||||
STAR_SUCCESS: "Nummer met ster",
|
||||
|
||||
@@ -163,6 +163,7 @@ export const HOLI_COLORS = [
|
||||
export type ICON =
|
||||
| "artists"
|
||||
| "albums"
|
||||
| "radio"
|
||||
| "playlists"
|
||||
| "genres"
|
||||
| "random"
|
||||
@@ -240,6 +241,7 @@ const iconFrom = (name: string) =>
|
||||
export const ICONS: Record<ICON, SvgIcon> = {
|
||||
artists: iconFrom("navidrome-artists.svg"),
|
||||
albums: iconFrom("navidrome-all.svg"),
|
||||
radio: iconFrom("navidrome-radio.svg"),
|
||||
blank: iconFrom("blank.svg"),
|
||||
playlists: iconFrom("navidrome-playlists.svg"),
|
||||
genres: iconFrom("Theatre-Mask-111172.svg"),
|
||||
|
||||
@@ -46,6 +46,10 @@ export type Genre = {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type Year = {
|
||||
year: string;
|
||||
}
|
||||
|
||||
export type Rating = {
|
||||
love: boolean;
|
||||
stars: number;
|
||||
@@ -69,6 +73,13 @@ export type Track = {
|
||||
rating: Rating;
|
||||
};
|
||||
|
||||
export type RadioStation = {
|
||||
id: string,
|
||||
name: string,
|
||||
url: string,
|
||||
homePage?: string
|
||||
}
|
||||
|
||||
export type Paging = {
|
||||
_index: number;
|
||||
_count: number;
|
||||
@@ -93,11 +104,13 @@ export const asResult = <T>([results, total]: [T[], number]) => ({
|
||||
|
||||
export type ArtistQuery = Paging;
|
||||
|
||||
export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'random' | 'recentlyPlayed' | 'mostPlayed' | 'recentlyAdded' | 'favourited' | 'starred';
|
||||
export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'byYear' | 'random' | 'recentlyPlayed' | 'mostPlayed' | 'recentlyAdded' | 'favourited' | 'starred';
|
||||
|
||||
export type AlbumQuery = Paging & {
|
||||
type: AlbumQueryType;
|
||||
genre?: string;
|
||||
fromYear?: string;
|
||||
toYear?: string;
|
||||
};
|
||||
|
||||
export const artistToArtistSummary = (it: Artist): ArtistSummary => ({
|
||||
@@ -166,6 +179,7 @@ export interface MusicLibrary {
|
||||
tracks(albumId: string): Promise<Track[]>;
|
||||
track(trackId: string): Promise<Track>;
|
||||
genres(): Promise<Genre[]>;
|
||||
years(): Promise<Year[]>;
|
||||
stream({
|
||||
trackId,
|
||||
range,
|
||||
@@ -188,4 +202,6 @@ export interface MusicLibrary {
|
||||
removeFromPlaylist(playlistId: string, indicies: number[]): Promise<boolean>
|
||||
similarSongs(id: string): Promise<Track[]>;
|
||||
topSongs(artistId: string): Promise<Track[]>;
|
||||
radioStation(id: string): Promise<RadioStation>
|
||||
radioStations(): Promise<RadioStation[]>
|
||||
}
|
||||
|
||||
152
src/smapi.ts
152
src/smapi.ts
@@ -15,8 +15,10 @@ import {
|
||||
AlbumSummary,
|
||||
ArtistSummary,
|
||||
Genre,
|
||||
Year,
|
||||
MusicService,
|
||||
Playlist,
|
||||
RadioStation,
|
||||
Rating,
|
||||
slice2,
|
||||
Track,
|
||||
@@ -243,12 +245,19 @@ export type Container = {
|
||||
};
|
||||
|
||||
const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
|
||||
itemType: "container",
|
||||
itemType: "albumList",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(),
|
||||
});
|
||||
|
||||
const year = (bonobUrl: URLBuilder, year: Year) => ({
|
||||
itemType: "albumList",
|
||||
id: `year:${year.year}`,
|
||||
title: year.year,
|
||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||
});
|
||||
|
||||
const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
|
||||
itemType: "playlist",
|
||||
id: `playlist:${playlist.id}`,
|
||||
@@ -299,6 +308,13 @@ export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
|
||||
// canAddToFavorites: true
|
||||
});
|
||||
|
||||
export const internetRadioStation = (station: RadioStation) => ({
|
||||
itemType: "stream",
|
||||
id: `internetRadioStation:${station.id}`,
|
||||
title: station.name,
|
||||
mimeType: "audio/mpeg",
|
||||
});
|
||||
|
||||
export const track = (bonobUrl: URLBuilder, track: Track) => ({
|
||||
itemType: "track",
|
||||
id: `track:${track.id}`,
|
||||
@@ -426,9 +442,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
},
|
||||
},
|
||||
})),
|
||||
TE.getOrElse(() =>
|
||||
T.of(SMAPI_FAULT_LOGIN_UNAUTHORIZED)
|
||||
)
|
||||
TE.getOrElse(() => T.of(SMAPI_FAULT_LOGIN_UNAUTHORIZED))
|
||||
)();
|
||||
} else {
|
||||
throw authOrFail.toSmapiFault();
|
||||
@@ -487,27 +501,38 @@ function bindSmapiSoapServiceToExpress(
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ credentials, type, typeId }) => ({
|
||||
getMediaURIResult: bonobUrl
|
||||
.append({
|
||||
pathname: `/stream/${type}/${typeId}`,
|
||||
})
|
||||
.href(),
|
||||
httpHeaders: [
|
||||
{
|
||||
httpHeader: {
|
||||
header: "bnbt",
|
||||
value: credentials.loginToken.token,
|
||||
},
|
||||
},
|
||||
{
|
||||
httpHeader: {
|
||||
header: "bnbk",
|
||||
value: credentials.loginToken.key,
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
.then(({ musicLibrary, credentials, type, typeId }) => {
|
||||
switch (type) {
|
||||
case "internetRadioStation":
|
||||
return musicLibrary.radioStation(typeId).then((it) => ({
|
||||
getMediaURIResult: it.url,
|
||||
}));
|
||||
case "track":
|
||||
return {
|
||||
getMediaURIResult: bonobUrl
|
||||
.append({
|
||||
pathname: `/stream/${type}/${typeId}`,
|
||||
})
|
||||
.href(),
|
||||
httpHeaders: [
|
||||
{
|
||||
httpHeader: {
|
||||
header: "bnbt",
|
||||
value: credentials.loginToken.token,
|
||||
},
|
||||
},
|
||||
{
|
||||
httpHeader: {
|
||||
header: "bnbk",
|
||||
value: credentials.loginToken.key,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
default:
|
||||
throw `Unsupported type:${type}`;
|
||||
}
|
||||
}),
|
||||
getMediaMetadata: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
@@ -515,11 +540,20 @@ function bindSmapiSoapServiceToExpress(
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(async ({ musicLibrary, apiKey, typeId }) =>
|
||||
musicLibrary.track(typeId!).then((it) => ({
|
||||
getMediaMetadataResult: track(urlWithToken(apiKey), it),
|
||||
}))
|
||||
),
|
||||
.then(async ({ musicLibrary, apiKey, type, typeId }) => {
|
||||
switch (type) {
|
||||
case "internetRadioStation":
|
||||
return musicLibrary.radioStation(typeId).then((it) => ({
|
||||
getMediaMetadataResult: internetRadioStation(it),
|
||||
}));
|
||||
case "track":
|
||||
return musicLibrary.track(typeId!).then((it) => ({
|
||||
getMediaMetadataResult: track(urlWithToken(apiKey), it),
|
||||
}));
|
||||
default:
|
||||
throw `Unsupported type:${type}`;
|
||||
}
|
||||
}),
|
||||
search: async (
|
||||
{ id, term }: { id: string; term: string },
|
||||
_,
|
||||
@@ -714,6 +748,12 @@ function bindSmapiSoapServiceToExpress(
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "years",
|
||||
title: lang("years"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "recentlyAdded",
|
||||
title: lang("recentlyAdded"),
|
||||
@@ -741,6 +781,12 @@ function bindSmapiSoapServiceToExpress(
|
||||
).href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "internetRadio",
|
||||
title: lang("internetRadio"),
|
||||
albumArtURI: iconArtURI(bonobUrl, "radio").href(),
|
||||
itemType: "stream",
|
||||
},
|
||||
],
|
||||
});
|
||||
case "search":
|
||||
@@ -785,6 +831,13 @@ function bindSmapiSoapServiceToExpress(
|
||||
genre: typeId,
|
||||
...paging,
|
||||
});
|
||||
case "year":
|
||||
return albums({
|
||||
type: "byYear",
|
||||
fromYear: typeId,
|
||||
toYear: typeId,
|
||||
...paging,
|
||||
});
|
||||
case "randomAlbums":
|
||||
return albums({
|
||||
type: "random",
|
||||
@@ -815,6 +868,32 @@ function bindSmapiSoapServiceToExpress(
|
||||
type: "mostPlayed",
|
||||
...paging,
|
||||
});
|
||||
case "internetRadio":
|
||||
return musicLibrary
|
||||
.radioStations()
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) =>
|
||||
getMetadataResult({
|
||||
mediaMetadata: page.map((it) =>
|
||||
internetRadioStation(it)
|
||||
),
|
||||
index: paging._index,
|
||||
total,
|
||||
})
|
||||
);
|
||||
case "years":
|
||||
return musicLibrary
|
||||
.years()
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) =>
|
||||
getMetadataResult({
|
||||
mediaCollection: page.map((it) =>
|
||||
year(bonobUrl, it)
|
||||
),
|
||||
index: paging._index,
|
||||
total,
|
||||
})
|
||||
);
|
||||
case "genres":
|
||||
return musicLibrary
|
||||
.genres()
|
||||
@@ -840,10 +919,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
name: playlist.name,
|
||||
coverArt: playlist.coverArt,
|
||||
// todo: are these every important?
|
||||
entries: []
|
||||
entries: [],
|
||||
};
|
||||
}
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
.then(slice2(paging))
|
||||
@@ -875,15 +953,15 @@ function bindSmapiSoapServiceToExpress(
|
||||
.artist(typeId!)
|
||||
.then((artist) => artist.albums)
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) => {
|
||||
return getMetadataResult({
|
||||
.then(([page, total]) =>
|
||||
getMetadataResult({
|
||||
mediaCollection: page.map((it) =>
|
||||
album(urlWithToken(apiKey), it)
|
||||
),
|
||||
index: paging._index,
|
||||
total,
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
case "relatedArtists":
|
||||
return musicLibrary
|
||||
.artist(typeId!)
|
||||
|
||||
@@ -205,6 +205,15 @@ type GetTopSongsResponse = {
|
||||
topSongs: { song: song[] };
|
||||
};
|
||||
|
||||
type GetInternetRadioStationsResponse = {
|
||||
internetRadioStations: { internetRadioStation: {
|
||||
id: string,
|
||||
name: string,
|
||||
streamUrl: string,
|
||||
homePageUrl?: string }[]
|
||||
}
|
||||
}
|
||||
|
||||
type GetSongResponse = {
|
||||
song: song;
|
||||
};
|
||||
@@ -337,6 +346,10 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
||||
O.getOrElseW(() => undefined)
|
||||
);
|
||||
|
||||
export const asYear = (year: string) => ({
|
||||
year: year,
|
||||
});
|
||||
|
||||
export interface CustomPlayers {
|
||||
encodingFor({ mimeType }: { mimeType: string }): O.Option<Encoding>
|
||||
}
|
||||
@@ -437,6 +450,7 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
||||
alphabeticalByArtist: "alphabeticalByArtist",
|
||||
alphabeticalByName: "alphabeticalByName",
|
||||
byGenre: "byGenre",
|
||||
byYear: "byYear",
|
||||
random: "random",
|
||||
recentlyPlayed: "recent",
|
||||
mostPlayed: "frequent",
|
||||
@@ -711,6 +725,8 @@ export class Subsonic implements MusicService {
|
||||
this.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
|
||||
type: AlbumQueryTypeToSubsonicType[q.type],
|
||||
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
|
||||
...(q.fromYear ? { fromYear: q.fromYear} : {}),
|
||||
...(q.toYear ? { toYear: q.toYear} : {}),
|
||||
size: 500,
|
||||
offset: q._index,
|
||||
})
|
||||
@@ -1011,6 +1027,41 @@ export class Subsonic implements MusicService {
|
||||
)
|
||||
)
|
||||
),
|
||||
radioStations: async () => subsonic
|
||||
.getJSON<GetInternetRadioStationsResponse>(
|
||||
credentials,
|
||||
"/rest/getInternetRadioStations"
|
||||
)
|
||||
.then((it) => it.internetRadioStations.internetRadioStation || [])
|
||||
.then((stations) => stations.map((it) => ({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
url: it.streamUrl,
|
||||
homePage: it.homePageUrl
|
||||
}))),
|
||||
radioStation: async (id: string) => genericSubsonic
|
||||
.radioStations()
|
||||
.then(it =>
|
||||
it.find(station => station.id === id)!
|
||||
),
|
||||
years: async () => {
|
||||
const q: AlbumQuery = {
|
||||
_index: 0,
|
||||
_count: 100000, // FIXME: better than this ?
|
||||
type: "alphabeticalByArtist",
|
||||
};
|
||||
const years = subsonic.getAlbumList2(credentials, q)
|
||||
.then(({ results }) =>
|
||||
results.map((album) => album.year || "?")
|
||||
.filter((item, i, ar) => ar.indexOf(item) === i)
|
||||
.sort()
|
||||
.map((year) => ({
|
||||
...asYear(year)
|
||||
}))
|
||||
.reverse()
|
||||
);
|
||||
return years;
|
||||
}
|
||||
};
|
||||
|
||||
if (credentials.type == "navidrome") {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Playlist,
|
||||
SimilarArtist,
|
||||
AlbumSummary,
|
||||
RadioStation,
|
||||
} from "../src/music_service";
|
||||
|
||||
import { b64Encode } from "../src/b64";
|
||||
@@ -165,6 +166,12 @@ export const SAMPLE_GENRES = [
|
||||
];
|
||||
export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)];
|
||||
|
||||
export const aYear = (year: string) => ({ id: year, year });
|
||||
|
||||
export const Y2024 = aYear("2024");
|
||||
export const Y2023 = aYear("2023");
|
||||
export const Y1969 = aYear("1969");
|
||||
|
||||
export function aTrack(fields: Partial<Track> = {}): Track {
|
||||
const id = uuid();
|
||||
const artist = anArtist();
|
||||
@@ -204,6 +211,17 @@ export function anAlbum(fields: Partial<Album> = {}): Album {
|
||||
};
|
||||
};
|
||||
|
||||
export function aRadioStation(fields: Partial<RadioStation> = {}): RadioStation {
|
||||
const id = uuid()
|
||||
const name = `Station-${id}`;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
url: `http://example.com/${name}`,
|
||||
...fields
|
||||
}
|
||||
}
|
||||
|
||||
export function anAlbumSummary(fields: Partial<AlbumSummary> = {}): AlbumSummary {
|
||||
const id = uuid();
|
||||
return {
|
||||
|
||||
@@ -161,6 +161,9 @@ export class InMemoryMusicService implements MusicService {
|
||||
Promise.reject("Unsupported operation"),
|
||||
similarSongs: async (_: string) => Promise.resolve([]),
|
||||
topSongs: async (_: string) => Promise.resolve([]),
|
||||
radioStations: async () => Promise.resolve([]),
|
||||
radioStation: async (_: string) => Promise.reject("Unsupported operation"),
|
||||
years: async () => Promise.resolve([]),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
sonosifyMimeType,
|
||||
ratingAsInt,
|
||||
ratingFromInt,
|
||||
internetRadioStation
|
||||
} from "../src/smapi";
|
||||
|
||||
import { keys as i8nKeys } from "../src/i8n";
|
||||
@@ -38,7 +39,11 @@ import {
|
||||
ROCK,
|
||||
TRIP_HOP,
|
||||
PUNK,
|
||||
Y2024,
|
||||
Y2023,
|
||||
Y1969,
|
||||
aPlaylist,
|
||||
aRadioStation,
|
||||
} from "./builders";
|
||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||
import supersoap from "./supersoap";
|
||||
@@ -481,6 +486,18 @@ describe("album", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("internetRadioStation", () => {
|
||||
it("should map to a sonos internet stream", () => {
|
||||
const station = aRadioStation()
|
||||
expect(internetRadioStation(station)).toEqual({
|
||||
itemType: "stream",
|
||||
id: `internetRadioStation:${station.id}`,
|
||||
title: station.name,
|
||||
mimeType: "audio/mpeg"
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe("sonosifyMimeType", () => {
|
||||
describe("when is audio/x-flac", () => {
|
||||
it("should be mapped to audio/flac", () => {
|
||||
@@ -561,6 +578,8 @@ describe("wsdl api", () => {
|
||||
artists: jest.fn(),
|
||||
artist: jest.fn(),
|
||||
genres: jest.fn(),
|
||||
years: jest.fn(),
|
||||
year: jest.fn(),
|
||||
playlists: jest.fn(),
|
||||
playlist: jest.fn(),
|
||||
album: jest.fn(),
|
||||
@@ -577,6 +596,8 @@ describe("wsdl api", () => {
|
||||
scrobble: jest.fn(),
|
||||
nowPlaying: jest.fn(),
|
||||
rate: jest.fn(),
|
||||
radioStation: jest.fn(),
|
||||
radioStations: jest.fn(),
|
||||
};
|
||||
const apiTokens = {
|
||||
mint: jest.fn(),
|
||||
@@ -1137,6 +1158,12 @@ describe("wsdl api", () => {
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "years",
|
||||
title: "Years",
|
||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "recentlyAdded",
|
||||
title: "Recently added",
|
||||
@@ -1158,6 +1185,12 @@ describe("wsdl api", () => {
|
||||
albumArtURI: iconArtURI(bonobUrl, "mostPlayed").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "internetRadio",
|
||||
title: "Internet Radio",
|
||||
albumArtURI: iconArtURI(bonobUrl, "radio").href(),
|
||||
itemType: "stream",
|
||||
},
|
||||
];
|
||||
expect(root[0]).toEqual(
|
||||
getMetadataResult({
|
||||
@@ -1225,6 +1258,12 @@ describe("wsdl api", () => {
|
||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "years",
|
||||
title: "Jaren",
|
||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
||||
itemType: "container",
|
||||
},
|
||||
{
|
||||
id: "recentlyAdded",
|
||||
title: "Onlangs toegevoegd",
|
||||
@@ -1246,6 +1285,12 @@ describe("wsdl api", () => {
|
||||
albumArtURI: iconArtURI(bonobUrl, "mostPlayed").href(),
|
||||
itemType: "albumList",
|
||||
},
|
||||
{
|
||||
id: "internetRadio",
|
||||
title: "Internet Radio",
|
||||
albumArtURI: iconArtURI(bonobUrl, "radio").href(),
|
||||
itemType: "stream",
|
||||
},
|
||||
];
|
||||
expect(root[0]).toEqual(
|
||||
getMetadataResult({
|
||||
@@ -1296,7 +1341,7 @@ describe("wsdl api", () => {
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: expectedGenres.map((genre) => ({
|
||||
itemType: "container",
|
||||
itemType: "albumList",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
albumArtURI: iconArtURI(
|
||||
@@ -1321,7 +1366,7 @@ describe("wsdl api", () => {
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: [PUNK, ROCK].map((genre) => ({
|
||||
itemType: "container",
|
||||
itemType: "albumList",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
albumArtURI: iconArtURI(
|
||||
@@ -1337,6 +1382,64 @@ describe("wsdl api", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for a year", () => {
|
||||
const expectedYears = [Y1969, Y2023, Y2024];
|
||||
|
||||
beforeEach(() => {
|
||||
musicLibrary.years.mockResolvedValue(expectedYears);
|
||||
});
|
||||
|
||||
describe("asking for all years", () => {
|
||||
it("should return a collection of years", async () => {
|
||||
const result = await ws.getMetadataAsync({
|
||||
id: `years`,
|
||||
index: 0,
|
||||
count: 100,
|
||||
});
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: expectedYears.map((year) => ({
|
||||
itemType: "albumList",
|
||||
id: `year:${year.id}`,
|
||||
title: year.year,
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"music",
|
||||
).href(),
|
||||
})),
|
||||
index: 0,
|
||||
total: expectedYears.length,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for a page of years", () => {
|
||||
it("should return just that page", async () => {
|
||||
const result = await ws.getMetadataAsync({
|
||||
id: `years`,
|
||||
index: 1,
|
||||
count: 2,
|
||||
});
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaCollection: [Y2023, Y2024].map((year) => ({
|
||||
itemType: "albumList",
|
||||
id: `year:${year.id}`,
|
||||
title: year.year,
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
"music"
|
||||
).href(),
|
||||
})),
|
||||
index: 1,
|
||||
total: expectedYears.length,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for playlists", () => {
|
||||
const playlist1 = aPlaylist({ id: "1", name: "pl1", entries: []});
|
||||
const playlist2 = aPlaylist({ id: "2", name: "pl2", entries: []});
|
||||
@@ -2375,6 +2478,71 @@ describe("wsdl api", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for internet radio stations", () => {
|
||||
const station1 = aRadioStation();
|
||||
const station2 = aRadioStation();
|
||||
const station3 = aRadioStation();
|
||||
const station4 = aRadioStation();
|
||||
|
||||
const stations = [station1, station2, station3, station4];
|
||||
|
||||
beforeEach(() => {
|
||||
musicLibrary.radioStations.mockResolvedValue(stations);
|
||||
});
|
||||
|
||||
describe("when they all fit on the page", () => {
|
||||
it("should return them all", async () => {
|
||||
const paging = {
|
||||
index: 0,
|
||||
count: 100,
|
||||
};
|
||||
|
||||
const result = await ws.getMetadataAsync({
|
||||
id: `internetRadio`,
|
||||
...paging,
|
||||
});
|
||||
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaMetadata: stations.map((it) =>
|
||||
internetRadioStation(it)
|
||||
),
|
||||
index: 0,
|
||||
total: stations.length,
|
||||
})
|
||||
);
|
||||
expect(musicLibrary.radioStations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for a single page of stations", () => {
|
||||
const pageOfStations = [station3, station4];
|
||||
|
||||
it("should return only that page", async () => {
|
||||
const paging = {
|
||||
index: 2,
|
||||
count: 2,
|
||||
};
|
||||
|
||||
const result = await ws.getMetadataAsync({
|
||||
id: `internetRadio`,
|
||||
...paging,
|
||||
});
|
||||
|
||||
expect(result[0]).toEqual(
|
||||
getMetadataResult({
|
||||
mediaMetadata: pageOfStations.map((it) =>
|
||||
internetRadioStation(it)
|
||||
),
|
||||
index: paging.index,
|
||||
total: stations.length,
|
||||
})
|
||||
);
|
||||
expect(musicLibrary.radioStations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2752,6 +2920,27 @@ describe("wsdl api", () => {
|
||||
expect(musicService.login).toHaveBeenCalledWith(serviceToken);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for a URI to stream a radio station", () => {
|
||||
const someStation = aRadioStation()
|
||||
|
||||
beforeEach(() => {
|
||||
musicLibrary.radioStation.mockResolvedValue(someStation);
|
||||
})
|
||||
|
||||
it("should return the radio stations uri", async () => {
|
||||
const root = await ws.getMediaURIAsync({
|
||||
id: `internetRadioStation:${someStation.id}`,
|
||||
});
|
||||
|
||||
expect(root[0]).toEqual({
|
||||
getMediaURIResult: someStation.url,
|
||||
});
|
||||
|
||||
expect(musicService.login).toHaveBeenCalledWith(serviceToken);
|
||||
expect(musicLibrary.radioStation).toHaveBeenCalledWith(someStation.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2763,7 +2952,6 @@ describe("wsdl api", () => {
|
||||
describe("when valid credentials are provided", () => {
|
||||
let ws: Client;
|
||||
|
||||
const someTrack = aTrack();
|
||||
|
||||
beforeEach(async () => {
|
||||
ws = await createClientAsync(`${service.uri}?wsdl`, {
|
||||
@@ -2771,10 +2959,15 @@ describe("wsdl api", () => {
|
||||
httpClient: supersoap(server),
|
||||
});
|
||||
setupAuthenticatedRequest(ws);
|
||||
musicLibrary.track.mockResolvedValue(someTrack);
|
||||
});
|
||||
|
||||
describe("asking for media metadata for a track", () => {
|
||||
const someTrack = aTrack();
|
||||
|
||||
beforeEach(async () => {
|
||||
musicLibrary.track.mockResolvedValue(someTrack);
|
||||
});
|
||||
|
||||
it("should return it with auth header", async () => {
|
||||
const root = await ws.getMediaMetadataAsync({
|
||||
id: `track:${someTrack.id}`,
|
||||
@@ -2793,6 +2986,27 @@ describe("wsdl api", () => {
|
||||
expect(musicLibrary.track).toHaveBeenCalledWith(someTrack.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for media metadata for an internet radio station", () => {
|
||||
const someStation = aRadioStation()
|
||||
|
||||
beforeEach(() => {
|
||||
musicLibrary.radioStation.mockResolvedValue(someStation);
|
||||
})
|
||||
|
||||
it("should return it with no auth header", async () => {
|
||||
const root = await ws.getMediaMetadataAsync({
|
||||
id: `internetRadioStation:${someStation.id}`,
|
||||
});
|
||||
|
||||
expect(root[0]).toEqual({
|
||||
getMediaMetadataResult: internetRadioStation(someStation),
|
||||
});
|
||||
expect(musicService.login).toHaveBeenCalledWith(serviceToken);
|
||||
expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken);
|
||||
expect(musicLibrary.radioStation).toHaveBeenCalledWith(someStation.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -49,7 +49,8 @@ import {
|
||||
SimilarArtist,
|
||||
Rating,
|
||||
Credentials,
|
||||
AuthFailure
|
||||
AuthFailure,
|
||||
RadioStation
|
||||
} from "../src/music_service";
|
||||
import {
|
||||
aGenre,
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
aTrack,
|
||||
POP,
|
||||
ROCK,
|
||||
aRadioStation
|
||||
} from "./builders";
|
||||
import { b64Encode } from "../src/b64";
|
||||
import { BUrn } from "../src/burn";
|
||||
@@ -348,6 +350,18 @@ const getArtistJson = (artist: Artist, extras: ArtistExtras = { artistImageUrl:
|
||||
artist: asArtistJson(artist, extras),
|
||||
});
|
||||
|
||||
const getRadioStationsJson = (radioStations: RadioStation[]) =>
|
||||
subsonicOK({
|
||||
internetRadioStations: {
|
||||
internetRadioStation: radioStations.map((it) => ({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
streamUrl: it.url,
|
||||
homePageUrl: it.homePage
|
||||
}))
|
||||
},
|
||||
});
|
||||
|
||||
const asGenreJson = (genre: { name: string; albumCount: number }) => ({
|
||||
songCount: 1475,
|
||||
albumCount: genre.albumCount,
|
||||
@@ -5028,4 +5042,86 @@ describe("Subsonic", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("radioStations", () => {
|
||||
beforeEach(() => {
|
||||
customPlayers.encodingFor.mockReturnValue(O.none);
|
||||
});
|
||||
|
||||
describe("when there some radio stations", () => {
|
||||
const station1 = aRadioStation();
|
||||
const station2 = aRadioStation();
|
||||
const station3 = aRadioStation();
|
||||
|
||||
beforeEach(() => {
|
||||
mockGET
|
||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve(ok(getRadioStationsJson([
|
||||
station1,
|
||||
station2,
|
||||
station3,
|
||||
])))
|
||||
);
|
||||
});
|
||||
|
||||
describe("asking for all of them", () => {
|
||||
it("should return them all", async () => {
|
||||
const result = await login({ username, password })
|
||||
.then((it) => it.radioStations());
|
||||
|
||||
expect(result).toEqual([station1, station2, station3]);
|
||||
|
||||
expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), {
|
||||
params: asURLSearchParams({
|
||||
...authParams,
|
||||
f: "json"
|
||||
}),
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("asking for one of them", () => {
|
||||
it("should return it", async () => {
|
||||
const result = await login({ username, password })
|
||||
.then((it) => it.radioStation(station2.id));
|
||||
|
||||
expect(result).toEqual(station2);
|
||||
|
||||
expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), {
|
||||
params: asURLSearchParams({
|
||||
...authParams,
|
||||
f: "json"
|
||||
}),
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when there are no radio stations", () => {
|
||||
it("should return []", async () => {
|
||||
mockGET
|
||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve(ok(getRadioStationsJson([])))
|
||||
);
|
||||
|
||||
const result = await login({ username, password })
|
||||
.then((it) => it.radioStations());
|
||||
|
||||
expect(result).toEqual([]);
|
||||
|
||||
expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), {
|
||||
params: asURLSearchParams({
|
||||
...authParams,
|
||||
f: "json"
|
||||
}),
|
||||
headers,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
4
web/icons/navidrome-radio.svg
Normal file
4
web/icons/navidrome-radio.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M20 6H8.3l8.26-3.34L15.88 1 3.24 6.15C2.51 6.43 2 7.17 2 8v12c0 1.1.89 2 2 2h16c1.11 0 2-.9 2-2V8c0-1.11-.89-2-2-2zm0 2v3h-2V9h-2v2H4V8h16zM4 20v-7h16v7H4z"></path>
|
||||
<circle cx="8" cy="16.48" r="2.5"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 293 B |
Reference in New Issue
Block a user