mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Compare commits
10 Commits
1fd8e13668
...
sonos80
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e207fd483 | |||
|
|
2403d6cdc6 | ||
|
|
03434fb362 | ||
|
|
a47581c3fe | ||
|
|
48a71031c6 | ||
|
|
d0d51b02f6 | ||
|
|
1bec5957b9 | ||
|
|
95ea4ac54f | ||
|
|
cba84f669a | ||
|
|
dfdb62bea4 |
69
UPDATES.adoc
Normal file
69
UPDATES.adoc
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
= Updates for SMAPI
|
||||||
|
|
||||||
|
Run Bonob on your server.
|
||||||
|
|
||||||
|
== Updates made to original code
|
||||||
|
|
||||||
|
* Proper Token handling after login. Also handling of periodic token refresh. Something is still funky here after a day or two...
|
||||||
|
* Store Tokens in an SQLite database (in mounted `/config` directory).
|
||||||
|
* Added variable `BNB_TOKEN_CLEANUP_INTERVAL` with a default of `60` (minutes) to set how often expired tokens should be cleaned up out of the database.
|
||||||
|
* Multi-account logins. Register one Bonob and log in with multiple Navidrome users for easy account switching in the Sonos app.
|
||||||
|
* Global Search integration (Artist, Album, Track)
|
||||||
|
* Scrobbling support to Navidrome. After one song has been completely played the album will show up in the "Recently played" section.
|
||||||
|
* Playlist support. It shows both public and private (for the current account) playlists.
|
||||||
|
* Modernized Login page.
|
||||||
|
|
||||||
|
== To be done
|
||||||
|
|
||||||
|
* Remove all now unnecessary logic:
|
||||||
|
** Handling of `BNB_SONOS_SEED_HOST`
|
||||||
|
** Autoregistration with Sonos devices (`BNB_SONOS_AUTO_REGISTER`)
|
||||||
|
** Handling of `BNB_SONOS_DEVICE_DISCOVERY`
|
||||||
|
* Implement Thumbs Up/Down or Star ratings (this is probably a Sonos Service configuration thing - with maybe some code changes).
|
||||||
|
* Implement Playlist editing
|
||||||
|
|
||||||
|
== Running Bonob
|
||||||
|
|
||||||
|
Bonob now needs a volume to store the token database. In the example below that directory is `/var/containers/bonob`. Adapt as needed.
|
||||||
|
Also the example below uses a `bonob` user on the system with ID `1210` and group `100`. The database directory should be owned by that user.
|
||||||
|
|
||||||
|
Also for `BNB_SUBSONIC_URL` you can use the internal or external URL. So instead of `https://music.mydomain.com` you could use `http://192.168.1.100:4533` if your Navidrome runs on a server with IP `192.168.1.100`.
|
||||||
|
.Example systemd file (`/usr/lib/systemd/system/bonob.service`)
|
||||||
|
[source]
|
||||||
|
----
|
||||||
|
[Unit]
|
||||||
|
Description=Bonob container service
|
||||||
|
Wants=network.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Environment=PODMAN_SYSTEMD_UNIT=%n
|
||||||
|
Restart=always
|
||||||
|
ExecStartPre=-/usr/bin/podman rm -f bonob
|
||||||
|
ExecStart=/usr/bin/podman run --rm \
|
||||||
|
--name bonob \
|
||||||
|
--label "io.containers.autoupdate=image" \
|
||||||
|
--user 1210:100 \
|
||||||
|
--env BNB_SONOS_SERVICE_NAME="Navidrome" \
|
||||||
|
--env BNB_PORT=8200 \
|
||||||
|
--env BNB_URL="https://bonob.mydomain.com" \
|
||||||
|
--env BNB_SECRET="Some random string" \
|
||||||
|
--env BNB_SONOS_SERVICE_ID=Your Sonos ID \
|
||||||
|
--env BNB_SUBSONIC_URL=https://music.mydomain.com \
|
||||||
|
--env BNB_ICON_FOREGROUND_COLOR="black" \
|
||||||
|
--env BNB_ICON_BACKGROUND_COLOR="#65d7f4" \
|
||||||
|
--env BNB_SONOS_AUTO_REGISTER=false \
|
||||||
|
--env BNB_SONOS_DEVICE_DISCOVERY=false \
|
||||||
|
--env BNB_LOG_LEVEL="info" \
|
||||||
|
--env TZ="Europe/Vienna" \
|
||||||
|
--volume /var/containers/bonob:/config:Z \
|
||||||
|
--publish 8200:8200 \
|
||||||
|
quay.io/wkulhanek/bonob:latest
|
||||||
|
ExecStop=/usr/bin/podman rm -f bonob
|
||||||
|
StandardOutput=syslog
|
||||||
|
StandardError=syslog
|
||||||
|
SyslogIdentifier=bonob
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target default.target
|
||||||
|
----
|
||||||
45
UPDATES.md
45
UPDATES.md
@@ -1,45 +0,0 @@
|
|||||||
# Updates for SMAPI
|
|
||||||
|
|
||||||
Run Bonob on your server.
|
|
||||||
|
|
||||||
Bonob now needs a volume to store OAuth Tokens. In the example below that directory is `/var/containers/bonob`. Adapt as needed.
|
|
||||||
Also the example below uses a `bonob` user on the system with ID `1210` and group `100`. The directory should be owned by that user.
|
|
||||||
|
|
||||||
Example systemd file (`/usr/lib/systemd/system/bonob.service`):
|
|
||||||
```
|
|
||||||
[Unit]
|
|
||||||
Description=bonob Container Service
|
|
||||||
Wants=network.target
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Environment=PODMAN_SYSTEMD_UNIT=%n
|
|
||||||
Restart=always
|
|
||||||
ExecStartPre=-/usr/bin/podman rm -f bonob
|
|
||||||
ExecStart=/usr/bin/podman run --rm \
|
|
||||||
--name bonob \
|
|
||||||
--label "io.containers.autoupdate=image" \
|
|
||||||
--user 1210:100 \
|
|
||||||
--env BNB_SONOS_SERVICE_NAME="Navidrome" \
|
|
||||||
--env BNB_PORT=8200 \
|
|
||||||
--env BNB_URL="https://bonob.mydomain.com" \
|
|
||||||
--env BNB_SECRET="Some random string" \
|
|
||||||
--env BNB_SONOS_SERVICE_ID=Your Sonos ID \
|
|
||||||
--env BNB_SUBSONIC_URL=https://music.mydomain.com \
|
|
||||||
--env BNB_ICON_FOREGROUND_COLOR="black" \
|
|
||||||
--env BNB_ICON_BACKGROUND_COLOR="#65d7f4" \
|
|
||||||
--env BNB_SONOS_AUTO_REGISTER=false \
|
|
||||||
--env BNB_SONOS_DEVICE_DISCOVERY=false \
|
|
||||||
--env BNB_LOG_LEVEL="info" \
|
|
||||||
--env TZ="Europe/Vienna" \
|
|
||||||
--volume /var/containers/bonob:/config:Z \
|
|
||||||
--publish 8200:8200 \
|
|
||||||
quay.io/wkulhanek/bonob:latest
|
|
||||||
ExecStop=/usr/bin/podman rm -f bonob
|
|
||||||
StandardOutput=syslog
|
|
||||||
StandardError=syslog
|
|
||||||
SyslogIdentifier=bonob
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target default.target
|
|
||||||
```
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
+
|
+
|
||||||
image::images/about.png[]
|
image::images/about.png[]
|
||||||
|
|
||||||
* Navidrome running and available from the Internet. E.g. via https://music.mydomain.com
|
* Navidrome running and available from the server that Bonob is running on. This can be a public URL like https://music.mydomain.com or just a local URL like http://192.168.1.100:4533.
|
||||||
* Bonob running and available from the Internet. E.g. via https://bonob.mydomain.com
|
* Bonob running and available from the Internet. E.g. via https://bonob.mydomain.com
|
||||||
|
|
||||||
You can use any method to make these URLs available. Cloudflare Tunnels, Pangolin, reverse proxy, etc.
|
You can use any method to make these URLs available. Cloudflare Tunnels, Pangolin, reverse proxy, etc.
|
||||||
@@ -21,7 +21,7 @@ You can use any method to make these URLs available. Cloudflare Tunnels, Pangoli
|
|||||||
*** Service Name: Navidrome
|
*** Service Name: Navidrome
|
||||||
*** Service Availability: Global
|
*** Service Availability: Global
|
||||||
*** Checkbox checked
|
*** Checkbox checked
|
||||||
*** Website/Social Media URLs: https://music.mydomain.com (Some URL - e.g. your Navidrome server)
|
*** Website/Social Media URLs: https://music.mydomain.com (Some URL - e.g. your Navidrome server). This has to be a valid URL.
|
||||||
|
|
||||||
** Sonos Music API
|
** Sonos Music API
|
||||||
*** Integration ID: com.mydomain.music (your domain in reverse)
|
*** Integration ID: com.mydomain.music (your domain in reverse)
|
||||||
|
|||||||
23
src/smapi.ts
23
src/smapi.ts
@@ -250,11 +250,10 @@ class SonosSoap {
|
|||||||
logger.debug("Current tokens: " + JSON.stringify(this.tokenStore.getAll()));
|
logger.debug("Current tokens: " + JSON.stringify(this.tokenStore.getAll()));
|
||||||
return this.tokenStore.get(token);
|
return this.tokenStore.get(token);
|
||||||
}
|
}
|
||||||
associateCredentialsForToken(token: string, fullSmapiToken: SmapiToken, oldToken?:string) {
|
associateCredentialsForToken(token: string, fullSmapiToken: SmapiToken) {
|
||||||
logger.debug("Adding token: " + token + " " + JSON.stringify(fullSmapiToken));
|
logger.debug("Adding token: " + token + " " + JSON.stringify(fullSmapiToken));
|
||||||
if(oldToken) {
|
// Don't immediately delete old token to avoid race conditions
|
||||||
this.tokenStore.delete(oldToken);
|
// The cleanup process will handle expired tokens later
|
||||||
}
|
|
||||||
this.tokenStore.set(token, fullSmapiToken);
|
this.tokenStore.set(token, fullSmapiToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -488,11 +487,9 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
const swapToken = (expiredToken: string | undefined) => (newToken: SmapiToken) => {
|
const swapToken = (expiredToken: string | undefined) => (newToken: SmapiToken) => {
|
||||||
logger.debug("oldToken: " + expiredToken);
|
logger.debug("oldToken: " + expiredToken);
|
||||||
logger.debug("newToken: " + JSON.stringify(newToken));
|
logger.debug("newToken: " + JSON.stringify(newToken));
|
||||||
if (expiredToken) {
|
// Always add the new token, but don't immediately delete the old one
|
||||||
sonosSoap.associateCredentialsForToken(newToken.token, newToken, expiredToken);
|
// to avoid race conditions where Sonos might still be using the old token
|
||||||
} else {
|
sonosSoap.associateCredentialsForToken(newToken.token, newToken);
|
||||||
sonosSoap.associateCredentialsForToken(newToken.token, newToken);
|
|
||||||
}
|
|
||||||
return TE.right(newToken);
|
return TE.right(newToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,8 +537,6 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
|
throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
|
||||||
});
|
});
|
||||||
} else if (isExpiredTokenError(authOrFail)) {
|
} else if (isExpiredTokenError(authOrFail)) {
|
||||||
// Don't pass old token here to avoid circular reference issues with Jest/SOAP
|
|
||||||
// Old expired tokens will be cleaned up by TTL or manual cleanup later
|
|
||||||
logger.info("Token expired, attempting refresh...");
|
logger.info("Token expired, attempting refresh...");
|
||||||
throw await pipe(
|
throw await pipe(
|
||||||
musicService.refreshToken(authOrFail.expiredToken),
|
musicService.refreshToken(authOrFail.expiredToken),
|
||||||
@@ -549,7 +544,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
logger.info("Token refresh successful, issuing new SMAPI token");
|
logger.info("Token refresh successful, issuing new SMAPI token");
|
||||||
return smapiAuthTokens.issue(it.serviceToken);
|
return smapiAuthTokens.issue(it.serviceToken);
|
||||||
}),
|
}),
|
||||||
TE.tap(swapToken(undefined)),
|
TE.tap(swapToken(authOrFail.expiredToken)), // Pass the expired token to ensure it gets deleted
|
||||||
TE.map((newToken) => ({
|
TE.map((newToken) => ({
|
||||||
Fault: {
|
Fault: {
|
||||||
faultcode: "Client.TokenRefreshRequired",
|
faultcode: "Client.TokenRefreshRequired",
|
||||||
@@ -615,12 +610,10 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
throw fault.toSmapiFault();
|
throw fault.toSmapiFault();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
// Don't pass old token here to avoid circular reference issues with Jest/SOAP
|
|
||||||
// Old expired tokens will be cleaned up by TTL or manual cleanup later
|
|
||||||
return pipe(
|
return pipe(
|
||||||
musicService.refreshToken(serviceToken),
|
musicService.refreshToken(serviceToken),
|
||||||
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
|
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
|
||||||
TE.tap(swapToken(undefined)), // ignores the return value, like a tee or peek
|
TE.tap(swapToken(serviceToken)), // Pass the expired token to ensure it gets deleted
|
||||||
TE.map((it) => ({
|
TE.map((it) => ({
|
||||||
refreshAuthTokenResult: {
|
refreshAuthTokenResult: {
|
||||||
authToken: it.token,
|
authToken: it.token,
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ export class InMemorySmapiTokenStore implements SmapiTokenStore {
|
|||||||
// Do NOT delete ExpiredTokenError as those can still be refreshed
|
// Do NOT delete ExpiredTokenError as those can still be refreshed
|
||||||
if (E.isLeft(verifyResult)) {
|
if (E.isLeft(verifyResult)) {
|
||||||
const error = verifyResult.left;
|
const error = verifyResult.left;
|
||||||
// Only delete invalid tokens, not expired ones (which can be refreshed)
|
// Delete both invalid and expired tokens to prevent accumulation
|
||||||
if (error._tag === 'InvalidTokenError') {
|
if (error._tag === 'InvalidTokenError' || error._tag === 'ExpiredTokenError') {
|
||||||
logger.debug(`Deleting invalid token from in-memory store`);
|
logger.debug(`Deleting ${error._tag} token from in-memory store`);
|
||||||
delete this.tokens[tokenKey];
|
delete this.tokens[tokenKey];
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
}
|
}
|
||||||
@@ -146,9 +146,9 @@ export class FileSmapiTokenStore implements SmapiTokenStore {
|
|||||||
// Do NOT delete ExpiredTokenError as those can still be refreshed
|
// Do NOT delete ExpiredTokenError as those can still be refreshed
|
||||||
if (E.isLeft(verifyResult)) {
|
if (E.isLeft(verifyResult)) {
|
||||||
const error = verifyResult.left;
|
const error = verifyResult.left;
|
||||||
// Only delete invalid tokens, not expired ones (which can be refreshed)
|
// Delete both invalid and expired tokens to prevent accumulation
|
||||||
if (error._tag === 'InvalidTokenError') {
|
if (error._tag === 'InvalidTokenError' || error._tag === 'ExpiredTokenError') {
|
||||||
logger.debug(`Deleting invalid token from file store`);
|
logger.debug(`Deleting ${error._tag} token from file store`);
|
||||||
delete this.tokens[tokenKey];
|
delete this.tokens[tokenKey];
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,9 +126,9 @@ export class SQLiteSmapiTokenStore implements SmapiTokenStore {
|
|||||||
// Do NOT delete ExpiredTokenError as those can still be refreshed
|
// Do NOT delete ExpiredTokenError as those can still be refreshed
|
||||||
if (E.isLeft(verifyResult)) {
|
if (E.isLeft(verifyResult)) {
|
||||||
const error = verifyResult.left;
|
const error = verifyResult.left;
|
||||||
// Only delete invalid tokens, not expired ones (which can be refreshed)
|
// Delete both invalid and expired tokens to prevent accumulation
|
||||||
if (error._tag === 'InvalidTokenError') {
|
if (error._tag === 'InvalidTokenError' || error._tag === 'ExpiredTokenError') {
|
||||||
logger.debug(`Deleting invalid token from SQLite store`);
|
logger.debug(`Deleting ${error._tag} token from SQLite store`);
|
||||||
this.delete(tokenKey);
|
this.delete(tokenKey);
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user