Compare commits

..

10 Commits

Author SHA1 Message Date
8e207fd483 Udpates to docs 2025-10-26 12:01:07 +01:00
Wolfgang Kulhanek
2403d6cdc6 Another token expiration fix. 2025-10-24 15:07:09 +02:00
Wolfgang Kulhanek
03434fb362 More token refresh fixes 2025-10-24 14:56:56 +02:00
Wolfgang Kulhanek
a47581c3fe Fix tests 2025-10-24 14:47:53 +02:00
Wolfgang Kulhanek
48a71031c6 Update token management again 2025-10-24 13:42:55 +02:00
Wolfgang Kulhanek
d0d51b02f6 More formatting 2025-10-23 14:30:30 +02:00
Wolfgang Kulhanek
1bec5957b9 Formatting? 2025-10-23 14:29:42 +02:00
Wolfgang Kulhanek
95ea4ac54f Formatting 2025-10-23 14:28:00 +02:00
Wolfgang Kulhanek
cba84f669a Add source tag 2025-10-23 14:27:39 +02:00
Wolfgang Kulhanek
dfdb62bea4 More details in Updates.adoc 2025-10-23 14:25:43 +02:00
6 changed files with 88 additions and 71 deletions

69
UPDATES.adoc Normal file
View 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
----

View File

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

View File

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

View File

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

View File

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

View File

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