Compare commits

..

5 Commits

Author SHA1 Message Date
Jonathan Virga
0488f398c1 Add years menu (#202) 2024-04-23 10:06:18 +10:00
Simon J
e7f5f5871e Ability to play radio stations from subsonic api (#199) 2024-02-26 05:51:30 +11:00
Simon J
eb3124b705 README updates (#197) 2024-02-08 15:49:35 +11:00
Simon J
4b7be66385 Upgrade @svrooij/sonos to ^2.6.0-beta.7 (#195) 2024-02-08 12:36:36 +11:00
Simon J
212f6e34dc Update README.md (#196) 2024-02-08 12:36:26 +11:00
14 changed files with 623 additions and 132 deletions

View File

@@ -20,8 +20,9 @@
"customizations": {
"vscode": {
"extensions": [
"esbenp.prettier-vscode"
]
"esbenp.prettier-vscode",
"redhat.vscode-xml"
]
}
}
}

View File

@@ -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
![Spotify-ish](https://github.com/simojenki/bonob/blob/master/docs/images/spotify-ish.png?raw=true)
## Credits
- Icons courtesy of: [Navidrome](https://www.navidrome.org/), [Vectornator](https://www.vectornator.io/icons), and @jicho

130
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"),

View File

@@ -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[]>
}

View File

@@ -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!)

View File

@@ -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") {

View File

@@ -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 {

View File

@@ -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([]),
});
}

View File

@@ -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);
});
});
});
});

View File

@@ -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,
});
});
});
});
});

View 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