Compare commits

...

16 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
Wolfgang Kulhanek
1fd8e13668 Playlist update again 2025-10-22 18:25:15 +02:00
Wolfgang Kulhanek
ba52f201b9 Another attempt to fix playlists 2025-10-22 18:15:17 +02:00
Wolfgang Kulhanek
d69b442019 Change playlist handling 2025-10-22 17:53:46 +02:00
Wolfgang Kulhanek
53021a9da1 Fix tests 2025-10-22 17:33:00 +02:00
Wolfgang Kulhanek
f8ff9f30fb Add BNB_TOKEN_CLEANUP_INTERVAL variable and re-design login page 2025-10-22 17:24:56 +02:00
Wolfgang Kulhanek
f08004a4f1 Replace json config with SQLite database 2025-10-22 16:51:40 +02:00
21 changed files with 1312 additions and 162 deletions

91
DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,91 @@
# Bonob Source Code Documentation
This document provides an overview of the source files in the `src` directory, explaining the purpose and functionality of each.
### `api_tokens.ts`
Manages API tokens for authentication. It includes an in-memory implementation for storing and retrieving tokens, using SHA256 to mint new tokens.
### `app.ts`
This is the main entry point of the application. It initializes the server, configures the music service (Subsonic), and sets up the integration with Sonos. It reads the application config, sets up the Subsonic connection, and starts the Express server.
### `b64.ts`
Provides simple utility functions for Base64 encoding and decoding of strings.
### `burn.ts`
Handles the creation and parsing of "Bonob URNs" (BURNs), which are unique resource identifiers used within the system. It supports encryption and shorthand notations for more compact URNs.
### `clock.ts`
Provides an abstraction for time-related functions, which is useful for testing. It includes a `SystemClock` that uses the actual system time and a `FixedClock` for tests. It also contains logic for detecting special dates like Christmas and Halloween for seasonal features.
### `config.ts`
Manages the application's configuration by reading environment variables. It defines settings for the server, Sonos integration, Subsonic connection, and other features like scrobbling.
### `encryption.ts`
Implements encryption and decryption functionality using JSON Web Signatures (JWS). It provides a simple interface for encrypting and decrypting strings.
### `i8n.ts`
Handles internationalization (i18n) by providing translations for different languages supported by the Sonos app. It includes translations for UI elements and messages.
### `icon.ts`
Manages SVG icons used in the Sonos app. It allows for transformations like changing colors and applying special styles for festivals and holidays.
### `link_codes.ts`
Implements a system for linking Sonos devices with user accounts. It generates temporary codes that users can use to log in and associate their accounts.
### `logger.ts`
Configures the application-wide logger using Winston. It sets up logging levels and formats.
### `music_service.ts`
Defines the interfaces for a generic music service. This includes methods for authentication, browsing content (artists, albums, tracks), streaming audio, and managing playlists.
### `register.ts`
A command-line script used to register the Bonob service with Sonos devices on the local network.
### `registrar.ts`
Contains the core logic for registering the Bonob service with Sonos devices. It fetches service details from the Bonob server and sends the registration request to a Sonos device.
### `server.ts`
Sets up the Express web server. It defines the routes for the web interface, the Sonos Music API (SMAPI) endpoints, and audio streaming. It also handles user authentication and session management.
### `smapi_auth.ts`
Handles authentication for the Sonos Music API (SMAPI). It is responsible for issuing and verifying JWTs (JSON Web Tokens) that secure the communication between Sonos devices and the Bonob server.
### `smapi_token_store.ts`
Provides an interface and two implementations (in-memory and file-based) for storing SMAPI authentication tokens. This allows the server to persist user sessions.
### `smapi.ts`
Implements the Sonos Music API (SMAPI) using SOAP. This file is responsible for handling all the requests from Sonos devices, such as browsing music, searching, and getting track metadata.
### `sonos.ts`
Manages interactions with Sonos devices on the local network. This includes device discovery and service registration.
### `subsonic.ts`
Implements the `MusicService` interface for Subsonic-compatible media servers (like Navidrome). It handles all communication with the Subsonic API to fetch music data and stream audio.
### `url_builder.ts`
A utility class for building and manipulating URLs in a structured way.
### `utils.ts`
Contains miscellaneous utility functions used throughout the application, such as a function for tidying up XML strings.

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

384
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"@types/xmldom": "^0.1.34", "@types/xmldom": "^0.1.34",
"@xmldom/xmldom": "^0.9.7", "@xmldom/xmldom": "^0.9.7",
"axios": "^1.7.8", "axios": "^1.7.8",
"better-sqlite3": "^12.4.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"eta": "^2.2.0", "eta": "^2.2.0",
"express": "^4.18.3", "express": "^4.18.3",
@@ -44,6 +45,7 @@
"xpath": "^0.0.34" "xpath": "^0.0.34"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^5.0.1", "@types/chai": "^5.0.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/mocha": "^10.0.10", "@types/mocha": "^10.0.10",
@@ -1592,6 +1594,16 @@
"@babel/types": "^7.20.7" "@babel/types": "^7.20.7"
} }
}, },
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.5", "version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
@@ -2178,6 +2190,26 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true "dev": true
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/basic-auth": { "node_modules/basic-auth": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
@@ -2194,6 +2226,20 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
}, },
"node_modules/better-sqlite3": {
"version": "12.4.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -2203,6 +2249,26 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/blob-util": { "node_modules/blob-util": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz",
@@ -2328,6 +2394,30 @@
"node-int64": "^0.4.0" "node-int64": "^0.4.0"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -2490,6 +2580,12 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/ci-info": { "node_modules/ci-info": {
"version": "3.9.0", "version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
@@ -2772,6 +2868,21 @@
} }
} }
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
@@ -2795,6 +2906,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deepmerge": { "node_modules/deepmerge": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -3024,6 +3144,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -3155,6 +3284,15 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect": { "node_modules/expect": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
@@ -3320,6 +3458,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/filelist": { "node_modules/filelist": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -3485,6 +3629,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "11.2.0", "version": "11.2.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz",
@@ -3605,6 +3755,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -3799,6 +3955,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore-by-default": { "node_modules/ignore-by-default": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@@ -3897,6 +4073,12 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/iobuffer": { "node_modules/iobuffer": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.3.2.tgz", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.3.2.tgz",
@@ -5193,6 +5375,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": { "node_modules/minimalistic-assert": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -5210,6 +5404,21 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ml-array-max": { "node_modules/ml-array-max": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-1.2.4.tgz", "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-1.2.4.tgz",
@@ -5482,6 +5691,12 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/natural-compare": { "node_modules/natural-compare": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -5508,6 +5723,30 @@
"integrity": "sha512-+z6QY1SxkDk6CQJAeaIZKmcNubBCRP7J8DMQUBglz/sSkNsZoJ1kULjqk9skNPPplzs4i9PFhYrvNDdtQleF/A==", "integrity": "sha512-+z6QY1SxkDk6CQJAeaIZKmcNubBCRP7J8DMQUBglz/sSkNsZoJ1kULjqk9skNPPplzs4i9PFhYrvNDdtQleF/A==",
"dev": true "dev": true
}, },
"node_modules/node-abi": {
"version": "3.78.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz",
"integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-fetch": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -5898,6 +6137,32 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -5960,6 +6225,16 @@
"integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
"dev": true "dev": true
}, },
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/pure-rand": { "node_modules/pure-rand": {
"version": "6.0.4", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz",
@@ -6034,6 +6309,30 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.2.0", "version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
@@ -6417,6 +6716,51 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"dev": true "dev": true
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-swizzle": { "node_modules/simple-swizzle": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
@@ -6746,6 +7090,34 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/test-exclude": { "node_modules/test-exclude": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -6990,6 +7362,18 @@
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"optional": true "optional": true
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/two-product": { "node_modules/two-product": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/two-product/-/two-product-1.0.2.tgz", "resolved": "https://registry.npmjs.org/two-product/-/two-product-1.0.2.tgz",

View File

@@ -19,6 +19,7 @@
"@types/xmldom": "^0.1.34", "@types/xmldom": "^0.1.34",
"@xmldom/xmldom": "^0.9.7", "@xmldom/xmldom": "^0.9.7",
"axios": "^1.7.8", "axios": "^1.7.8",
"better-sqlite3": "^12.4.1",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"eta": "^2.2.0", "eta": "^2.2.0",
"express": "^4.18.3", "express": "^4.18.3",
@@ -41,6 +42,7 @@
"xpath": "^0.0.34" "xpath": "^0.0.34"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^5.0.1", "@types/chai": "^5.0.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/mocha": "^10.0.10", "@types/mocha": "^10.0.10",

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

@@ -18,7 +18,7 @@ import sonos, { bonobService } from "./sonos";
import { MusicService } from "./music_service"; import { MusicService } from "./music_service";
import { SystemClock } from "./clock"; import { SystemClock } from "./clock";
import { JWTSmapiLoginTokens } from "./smapi_auth"; import { JWTSmapiLoginTokens } from "./smapi_auth";
import { FileSmapiTokenStore } from "./smapi_token_store"; import { SQLiteSmapiTokenStore } from "./smapi_token_store";
const config = readConfig(); const config = readConfig();
const clock = SystemClock; const clock = SystemClock;
@@ -82,6 +82,16 @@ const version = fs.existsSync(GIT_INFO)
? fs.readFileSync(GIT_INFO).toString().trim() ? fs.readFileSync(GIT_INFO).toString().trim()
: "v??"; : "v??";
// Initialize SQLite token store
const smapiTokenStore = new SQLiteSmapiTokenStore(config.tokenStore.dbPath);
// Migrate existing JSON tokens if they exist
const legacyJsonPath = "/config/tokens.json";
if (fs.existsSync(legacyJsonPath)) {
logger.info(`Found legacy JSON token file at ${legacyJsonPath}, attempting migration...`);
smapiTokenStore.migrateFromJSON(legacyJsonPath);
}
const app = server( const app = server(
sonosSystem, sonosSystem,
bonob, bonob,
@@ -97,7 +107,8 @@ const app = server(
version, version,
smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout), smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout),
externalImageResolver: artistImageFetcher, externalImageResolver: artistImageFetcher,
smapiTokenStore: new FileSmapiTokenStore("/config/tokens.json") smapiTokenStore,
tokenCleanupIntervalMinutes: config.tokenStore.cleanupIntervalMinutes
} }
); );
@@ -126,6 +137,7 @@ process.on('SIGTERM', () => {
expressServer.close(() => { expressServer.close(() => {
logger.info('HTTP server closed'); logger.info('HTTP server closed');
}); });
smapiTokenStore.close();
process.exit(0); process.exit(0);
}); });

View File

@@ -105,5 +105,9 @@ export default function () {
scrobbleTracks: bnbEnvVar<boolean>("SCROBBLE_TRACKS", { default: true, parser: asBoolean }), scrobbleTracks: bnbEnvVar<boolean>("SCROBBLE_TRACKS", { default: true, parser: asBoolean }),
reportNowPlaying: reportNowPlaying:
bnbEnvVar<boolean>("REPORT_NOW_PLAYING", { default: true, parser: asBoolean }), bnbEnvVar<boolean>("REPORT_NOW_PLAYING", { default: true, parser: asBoolean }),
tokenStore: {
dbPath: bnbEnvVar<string>("TOKEN_DB_PATH", { default: "/config/tokens.db" })!,
cleanupIntervalMinutes: bnbEnvVar<number>("TOKEN_CLEANUP_INTERVAL", { default: 60, parser: asInt })!,
},
}; };
} }

View File

@@ -113,6 +113,7 @@ export type ServerOpts = {
smapiAuthTokens: SmapiAuthTokens; smapiAuthTokens: SmapiAuthTokens;
externalImageResolver: ImageFetcher; externalImageResolver: ImageFetcher;
smapiTokenStore: SmapiTokenStore; smapiTokenStore: SmapiTokenStore;
tokenCleanupIntervalMinutes: number;
}; };
const DEFAULT_SERVER_OPTS: ServerOpts = { const DEFAULT_SERVER_OPTS: ServerOpts = {
@@ -130,6 +131,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = {
), ),
externalImageResolver: axiosImageFetcher, externalImageResolver: axiosImageFetcher,
smapiTokenStore: new InMemorySmapiTokenStore(), smapiTokenStore: new InMemorySmapiTokenStore(),
tokenCleanupIntervalMinutes: 60,
}; };
function server( function server(
@@ -747,7 +749,8 @@ function server(
i8n, i8n,
serverOpts.smapiAuthTokens, serverOpts.smapiAuthTokens,
serverOpts.smapiTokenStore, serverOpts.smapiTokenStore,
serverOpts.logRequests serverOpts.logRequests,
serverOpts.tokenCleanupIntervalMinutes
); );
if (serverOpts.applyContextPath) { if (serverOpts.applyContextPath) {

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);
} }
} }
@@ -289,6 +288,7 @@ const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
title: playlist.name, title: playlist.name,
albumArtURI: coverArtURI(bonobUrl, playlist).href(), albumArtURI: coverArtURI(bonobUrl, playlist).href(),
canPlay: true, canPlay: true,
canEnumerate: true,
attributes: { attributes: {
readOnly: false, readOnly: false,
userContent: false, userContent: false,
@@ -406,7 +406,8 @@ function bindSmapiSoapServiceToExpress(
i8n: I8N, i8n: I8N,
smapiAuthTokens: SmapiAuthTokens, smapiAuthTokens: SmapiAuthTokens,
tokenStore: SmapiTokenStore, tokenStore: SmapiTokenStore,
_logRequests: boolean _logRequests: boolean,
tokenCleanupIntervalMinutes: number = 60
) { ) {
const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock, tokenStore); const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock, tokenStore);
@@ -420,14 +421,16 @@ function bindSmapiSoapServiceToExpress(
logger.error("Failed to cleanup expired tokens on startup", { error }); logger.error("Failed to cleanup expired tokens on startup", { error });
} }
// Clean up expired tokens every hour // Clean up expired tokens periodically
const cleanupIntervalMs = tokenCleanupIntervalMinutes * 60 * 1000;
logger.info(`Token cleanup will run every ${tokenCleanupIntervalMinutes} minute(s)`);
setInterval(() => { setInterval(() => {
try { try {
tokenStore.cleanupExpired(smapiAuthTokens); tokenStore.cleanupExpired(smapiAuthTokens);
} catch (error) { } catch (error) {
logger.error("Failed to cleanup expired tokens", { error }); logger.error("Failed to cleanup expired tokens", { error });
} }
}, 60 * 60 * 1000).unref(); // Run every hour, but don't prevent process exit }, cleanupIntervalMs).unref(); // Don't prevent process exit
const urlWithToken = (accessToken: string) => const urlWithToken = (accessToken: string) =>
bonobUrl.append({ bonobUrl.append({
@@ -484,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);
} }
@@ -536,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),
@@ -545,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",
@@ -611,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,
@@ -802,6 +799,30 @@ function bindSmapiSoapServiceToExpress(
// </getExtendedMetadataResult> // </getExtendedMetadataResult>
}, },
})); }));
case "playlists":
return musicLibrary
.playlists()
.then((it) =>
Promise.all(
it.map((playlist) => ({
id: playlist.id,
name: playlist.name,
coverArt: playlist.coverArt,
entries: [],
}))
)
)
.then(slice2(paging))
.then(([page, total]) => ({
getExtendedMetadataResult: {
count: page.length,
index: paging._index,
total,
mediaCollection: page.map((it) =>
playlist(urlWithToken(apiKey), it)
),
},
}));
default: default:
throw `Unsupported getExtendedMetadata id=${id}`; throw `Unsupported getExtendedMetadata id=${id}`;
} }
@@ -877,7 +898,8 @@ function bindSmapiSoapServiceToExpress(
id: "playlists", id: "playlists",
title: lang("playlists"), title: lang("playlists"),
albumArtURI: iconArtURI(bonobUrl, "playlists").href(), albumArtURI: iconArtURI(bonobUrl, "playlists").href(),
itemType: "playlist", itemType: "container",
canEnumerate: true,
attributes: { attributes: {
readOnly: false, readOnly: false,
userContent: true, userContent: true,

View File

@@ -4,6 +4,8 @@ import logger from "./logger";
import { SmapiToken, SmapiAuthTokens } from "./smapi_auth"; import { SmapiToken, SmapiAuthTokens } from "./smapi_auth";
import { either as E } from "fp-ts"; import { either as E } from "fp-ts";
export { SQLiteSmapiTokenStore } from "./sqlite_smapi_token_store";
export interface SmapiTokenStore { export interface SmapiTokenStore {
get(token: string): SmapiToken | undefined; get(token: string): SmapiToken | undefined;
set(token: string, fullSmapiToken: SmapiToken): void; set(token: string, fullSmapiToken: SmapiToken): void;
@@ -43,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++;
} }
@@ -144,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

@@ -0,0 +1,200 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import logger from "./logger";
import { SmapiToken, SmapiAuthTokens } from "./smapi_auth";
import { either as E } from "fp-ts";
import { SmapiTokenStore } from "./smapi_token_store";
export class SQLiteSmapiTokenStore implements SmapiTokenStore {
private db!: Database.Database;
private readonly dbPath: string;
constructor(dbPath: string) {
this.dbPath = dbPath;
this.initializeDatabase();
}
private initializeDatabase(): void {
try {
// Ensure the directory exists
const dir = path.dirname(this.dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
logger.info(`Created token storage directory: ${dir}`);
}
// Open database connection
this.db = new Database(this.dbPath);
// Create table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS smapi_tokens (
token_key TEXT PRIMARY KEY,
token TEXT NOT NULL,
key TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
)
`);
// Create index for faster lookups
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_created_at ON smapi_tokens(created_at)
`);
const count = this.db.prepare("SELECT COUNT(*) as count FROM smapi_tokens").get() as { count: number };
logger.info(`SQLite token store initialized at ${this.dbPath} with ${count.count} token(s)`);
} catch (error) {
logger.error(`Failed to initialize SQLite token store at ${this.dbPath}`, { error });
throw error;
}
}
get(tokenKey: string): SmapiToken | undefined {
try {
const stmt = this.db.prepare("SELECT token, key FROM smapi_tokens WHERE token_key = ?");
const row = stmt.get(tokenKey) as { token: string; key: string } | undefined;
if (!row) {
return undefined;
}
return {
token: row.token,
key: row.key,
};
} catch (error) {
logger.error(`Failed to get token from SQLite store`, { error });
return undefined;
}
}
set(tokenKey: string, fullSmapiToken: SmapiToken): void {
try {
const stmt = this.db.prepare(`
INSERT OR REPLACE INTO smapi_tokens (token_key, token, key)
VALUES (?, ?, ?)
`);
stmt.run(tokenKey, fullSmapiToken.token, fullSmapiToken.key);
logger.debug(`Saved token to SQLite store`);
} catch (error) {
logger.error(`Failed to save token to SQLite store`, { error });
}
}
delete(tokenKey: string): void {
try {
const stmt = this.db.prepare("DELETE FROM smapi_tokens WHERE token_key = ?");
stmt.run(tokenKey);
logger.debug(`Deleted token from SQLite store`);
} catch (error) {
logger.error(`Failed to delete token from SQLite store`, { error });
}
}
getAll(): { [tokenKey: string]: SmapiToken } {
try {
const stmt = this.db.prepare("SELECT token_key, token, key FROM smapi_tokens");
const rows = stmt.all() as Array<{ token_key: string; token: string; key: string }>;
const tokens: { [tokenKey: string]: SmapiToken } = {};
for (const row of rows) {
tokens[row.token_key] = {
token: row.token,
key: row.key,
};
}
return tokens;
} catch (error) {
logger.error(`Failed to get all tokens from SQLite store`, { error });
return {};
}
}
cleanupExpired(smapiAuthTokens: SmapiAuthTokens): number {
try {
const tokens = this.getAll();
const tokenKeys = Object.keys(tokens);
let deletedCount = 0;
for (const tokenKey of tokenKeys) {
const smapiToken = tokens[tokenKey];
if (smapiToken) {
const verifyResult = smapiAuthTokens.verify(smapiToken);
// Only delete if token verification fails with InvalidTokenError
// Do NOT delete ExpiredTokenError as those can still be refreshed
if (E.isLeft(verifyResult)) {
const error = verifyResult.left;
// Delete both invalid and expired tokens to prevent accumulation
if (error._tag === 'InvalidTokenError' || error._tag === 'ExpiredTokenError') {
logger.debug(`Deleting ${error._tag} token from SQLite store`);
this.delete(tokenKey);
deletedCount++;
}
}
}
}
if (deletedCount > 0) {
logger.info(`Cleaned up ${deletedCount} invalid token(s) from SQLite store`);
}
return deletedCount;
} catch (error) {
logger.error(`Failed to cleanup expired tokens from SQLite store`, { error });
return 0;
}
}
/**
* Migrate tokens from a JSON file to the SQLite database
* @param jsonFilePath Path to the JSON file containing tokens
* @returns Number of tokens migrated
*/
migrateFromJSON(jsonFilePath: string): number {
try {
if (!fs.existsSync(jsonFilePath)) {
logger.info(`No JSON token file found at ${jsonFilePath}, skipping migration`);
return 0;
}
const data = fs.readFileSync(jsonFilePath, "utf8");
const tokens: { [tokenKey: string]: SmapiToken } = JSON.parse(data);
const tokenKeys = Object.keys(tokens);
let migratedCount = 0;
for (const tokenKey of tokenKeys) {
const token = tokens[tokenKey];
if (token) {
this.set(tokenKey, token);
migratedCount++;
}
}
logger.info(`Migrated ${migratedCount} token(s) from ${jsonFilePath} to SQLite`);
// Optionally rename the old JSON file to .bak
const backupPath = `${jsonFilePath}.bak`;
fs.renameSync(jsonFilePath, backupPath);
logger.info(`Backed up original JSON file to ${backupPath}`);
return migratedCount;
} catch (error) {
logger.error(`Failed to migrate tokens from JSON file ${jsonFilePath}`, { error });
return 0;
}
}
/**
* Close the database connection
*/
close(): void {
try {
this.db.close();
logger.info("SQLite token store connection closed");
} catch (error) {
logger.error("Failed to close SQLite token store connection", { error });
}
}
}

View File

@@ -94,7 +94,7 @@ class SonosDriver {
.get(this.bonobUrl.append({ pathname: "/" }).pathname()) .get(this.bonobUrl.append({ pathname: "/" }).pathname())
.expect(200) .expect(200)
.then((response) => { .then((response) => {
const m = response.text.match(/ action="(.*)" /i); const m = response.text.match(/ action="([^"]+)"/i);
return m![1]!; return m![1]!;
}); });

View File

@@ -240,7 +240,7 @@ describe("server", () => {
.send(); .send();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("devices")} \(0\)</h2>`); expect(res.text).toMatch(new RegExp(`${lang("devices")}.*\\(0\\)`));
expect(res.text).not.toMatch(/class=device/); expect(res.text).not.toMatch(/class=device/);
expect(res.text).toContain(lang("noSonosDevices")); expect(res.text).toContain(lang("noSonosDevices"));
}); });
@@ -276,7 +276,7 @@ describe("server", () => {
.send(); .send();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("devices")} \(0\)</h2>`); expect(res.text).toMatch(new RegExp(`${lang("devices")}.*\\(0\\)`));
expect(res.text).not.toMatch(/class=device/); expect(res.text).not.toMatch(/class=device/);
expect(res.text).toContain(lang("noSonosDevices")); expect(res.text).toContain(lang("noSonosDevices"));
}); });
@@ -290,7 +290,7 @@ describe("server", () => {
.send(); .send();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("services")} \(0\)</h2>`); expect(res.text).toMatch(new RegExp(`${lang("services")}.*\\(0\\)`));
}); });
}); });
}); });
@@ -352,9 +352,9 @@ describe("server", () => {
.send(); .send();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("devices")} \(2\)</h2>`); expect(res.text).toMatch(new RegExp(`${lang("devices")}.*\\(2\\)`));
expect(res.text).toMatch(/device1\s+\(172.0.0.1:4301\)/); expect(res.text).toMatch(/device1.*172\.0\.0\.1:4301/);
expect(res.text).toMatch(/device2\s+\(172.0.0.2:4302\)/); expect(res.text).toMatch(/device2.*172\.0\.0\.2:4302/);
}); });
}); });
@@ -366,11 +366,11 @@ describe("server", () => {
.send(); .send();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.text).toMatch(`<h2>${lang("services")} \(4\)</h2>`); expect(res.text).toMatch(new RegExp(`${lang("services")}.*\\(4\\)`));
expect(res.text).toMatch(/s1\s+\(1\)/); expect(res.text).toMatch(/s1.*SID:\s*1/);
expect(res.text).toMatch(/s2\s+\(2\)/); expect(res.text).toMatch(/s2.*SID:\s*2/);
expect(res.text).toMatch(/s3\s+\(3\)/); expect(res.text).toMatch(/s3.*SID:\s*3/);
expect(res.text).toMatch(/s4\s+\(4\)/); expect(res.text).toMatch(/s4.*SID:\s*4/);
}); });
}); });
@@ -382,14 +382,11 @@ describe("server", () => {
.send(); .send();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.text).toMatch( expect(res.text).toMatch(
`<input type="submit" value="${lang("register")}">` `<input type="submit" value="${lang("register")}" id="submit">`
);
expect(res.text).toMatch(`<h3>${lang("expectedConfig")}</h3>`);
expect(res.text).toMatch(
`<h3>${lang("noExistingServiceRegistration")}</h3>`
); );
expect(res.text).toContain(lang("noExistingServiceRegistration"));
expect(res.text).not.toMatch( expect(res.text).not.toMatch(
`<input type="submit" value="${lang("removeRegistration")}">` `value="${lang("removeRegistration")}"`
); );
}); });
}); });
@@ -440,14 +437,11 @@ describe("server", () => {
.send(); .send();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.text).toMatch( expect(res.text).toMatch(
`<input type="submit" value="${lang("register")}">` `<input type="submit" value="${lang("register")}" id="submit">`
); );
expect(res.text).toMatch(`<h3>${lang("expectedConfig")}</h3>`); expect(res.text).toContain(lang("existingServiceConfig"));
expect(res.text).toMatch( expect(res.text).toMatch(
`<h3>${lang("existingServiceConfig")}</h3>` `<input type="submit" value="${lang("removeRegistration")}" id="submit"`
);
expect(res.text).toMatch(
`<input type="submit" value="${lang("removeRegistration")}">`
); );
}); });
}); });
@@ -632,13 +626,13 @@ describe("server", () => {
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
expect(res.text).toMatch(`<title>${lang("login")}</title>`); expect(res.text).toMatch(`<title>${lang("login")}</title>`);
expect(res.text).toMatch( expect(res.text).toMatch(
`<h1 class="login one-word-per-line">${lang("logInToBonob")}</h1>` `<h1>${lang("logInToBonob")}</h1>`
); );
expect(res.text).toMatch( expect(res.text).toMatch(
`<label for="username">${lang("username")}:</label>` `<label for="username">${lang("username")}</label>`
); );
expect(res.text).toMatch( expect(res.text).toMatch(
`<label for="password">${lang("password")}:</label>` `<label for="password">${lang("password")}</label>`
); );
expect(res.text).toMatch( expect(res.text).toMatch(
`<input type="submit" value="${lang("login")}" id="submit">` `<input type="submit" value="${lang("login")}" id="submit">`

View File

@@ -1160,7 +1160,8 @@ describe("wsdl api", () => {
id: "playlists", id: "playlists",
title: "Playlists", title: "Playlists",
albumArtURI: iconArtURI(bonobUrl, "playlists").href(), albumArtURI: iconArtURI(bonobUrl, "playlists").href(),
itemType: "playlist", itemType: "container",
canEnumerate: true,
attributes: { attributes: {
readOnly: "false", readOnly: "false",
renameable: "false", renameable: "false",
@@ -1260,7 +1261,8 @@ describe("wsdl api", () => {
id: "playlists", id: "playlists",
title: "Afspeellijsten", title: "Afspeellijsten",
albumArtURI: iconArtURI(bonobUrl, "playlists").href(), albumArtURI: iconArtURI(bonobUrl, "playlists").href(),
itemType: "playlist", itemType: "container",
canEnumerate: true,
attributes: { attributes: {
readOnly: "false", readOnly: "false",
renameable: "false", renameable: "false",
@@ -1497,6 +1499,7 @@ describe("wsdl api", () => {
playlist playlist
).href(), ).href(),
canPlay: true, canPlay: true,
canEnumerate: true,
attributes: { attributes: {
readOnly: "false", readOnly: "false",
userContent: "false", userContent: "false",
@@ -1529,6 +1532,7 @@ describe("wsdl api", () => {
playlist playlist
).href(), ).href(),
canPlay: true, canPlay: true,
canEnumerate: true,
attributes: { attributes: {
readOnly: "false", readOnly: "false",
userContent: "false", userContent: "false",

View File

@@ -0,0 +1,234 @@
import fs from "fs";
import path from "path";
import { SQLiteSmapiTokenStore } from "../src/sqlite_smapi_token_store";
import { SmapiToken } from "../src/smapi_auth";
import { JWTSmapiLoginTokens } from "../src/smapi_auth";
import { SystemClock } from "../src/clock";
describe("SQLiteSmapiTokenStore", () => {
const testDbPath = path.join(__dirname, "test-tokens.db");
const testJsonPath = path.join(__dirname, "test-tokens.json");
let tokenStore: SQLiteSmapiTokenStore;
beforeEach(() => {
// Clean up any existing test files
if (fs.existsSync(testDbPath)) {
fs.unlinkSync(testDbPath);
}
if (fs.existsSync(testJsonPath)) {
fs.unlinkSync(testJsonPath);
}
if (fs.existsSync(`${testJsonPath}.bak`)) {
fs.unlinkSync(`${testJsonPath}.bak`);
}
tokenStore = new SQLiteSmapiTokenStore(testDbPath);
});
afterEach(() => {
tokenStore.close();
// Clean up test files
if (fs.existsSync(testDbPath)) {
fs.unlinkSync(testDbPath);
}
if (fs.existsSync(testJsonPath)) {
fs.unlinkSync(testJsonPath);
}
if (fs.existsSync(`${testJsonPath}.bak`)) {
fs.unlinkSync(`${testJsonPath}.bak`);
}
});
describe("Database Initialization", () => {
it("should create database file on initialization", () => {
expect(fs.existsSync(testDbPath)).toBe(true);
});
it("should create parent directory if it doesn't exist", () => {
const nestedPath = path.join(__dirname, "nested", "dir", "tokens.db");
const nestedStore = new SQLiteSmapiTokenStore(nestedPath);
expect(fs.existsSync(nestedPath)).toBe(true);
nestedStore.close();
fs.unlinkSync(nestedPath);
fs.rmdirSync(path.dirname(nestedPath));
fs.rmdirSync(path.dirname(path.dirname(nestedPath)));
});
});
describe("Token Operations", () => {
const testToken: SmapiToken = {
token: "test-jwt-token",
key: "test-key-123",
};
it("should set and get a token", () => {
tokenStore.set("token1", testToken);
const retrieved = tokenStore.get("token1");
expect(retrieved).toEqual(testToken);
});
it("should return undefined for non-existent token", () => {
const retrieved = tokenStore.get("non-existent");
expect(retrieved).toBeUndefined();
});
it("should update existing token", () => {
tokenStore.set("token1", testToken);
const updatedToken: SmapiToken = {
token: "updated-jwt-token",
key: "updated-key-456",
};
tokenStore.set("token1", updatedToken);
const retrieved = tokenStore.get("token1");
expect(retrieved).toEqual(updatedToken);
});
it("should delete a token", () => {
tokenStore.set("token1", testToken);
tokenStore.delete("token1");
const retrieved = tokenStore.get("token1");
expect(retrieved).toBeUndefined();
});
it("should get all tokens", () => {
const token1: SmapiToken = { token: "jwt1", key: "key1" };
const token2: SmapiToken = { token: "jwt2", key: "key2" };
const token3: SmapiToken = { token: "jwt3", key: "key3" };
tokenStore.set("tokenKey1", token1);
tokenStore.set("tokenKey2", token2);
tokenStore.set("tokenKey3", token3);
const allTokens = tokenStore.getAll();
expect(Object.keys(allTokens).length).toBe(3);
expect(allTokens["tokenKey1"]).toEqual(token1);
expect(allTokens["tokenKey2"]).toEqual(token2);
expect(allTokens["tokenKey3"]).toEqual(token3);
});
it("should return empty object when no tokens exist", () => {
const allTokens = tokenStore.getAll();
expect(allTokens).toEqual({});
});
});
describe("Token Cleanup", () => {
it("should cleanup invalid tokens", () => {
const smapiAuthTokens = new JWTSmapiLoginTokens(SystemClock, "test-secret", "1h");
// Create valid tokens
const validToken1 = smapiAuthTokens.issue("service-token-1");
const validToken2 = smapiAuthTokens.issue("service-token-2");
// Create invalid token (wrong secret)
const invalidAuthTokens = new JWTSmapiLoginTokens(SystemClock, "different-secret", "1h");
const invalidToken = invalidAuthTokens.issue("service-token-3");
tokenStore.set("valid1", validToken1);
tokenStore.set("valid2", validToken2);
tokenStore.set("invalid", invalidToken);
// Clean up
const deletedCount = tokenStore.cleanupExpired(smapiAuthTokens);
expect(deletedCount).toBe(1);
expect(tokenStore.get("valid1")).toBeDefined();
expect(tokenStore.get("valid2")).toBeDefined();
expect(tokenStore.get("invalid")).toBeUndefined();
});
it("should not cleanup expired tokens that can be refreshed", () => {
// Note: This test would require mocking time to create an expired token
// For now, we just verify the function runs without error
const smapiAuthTokens = new JWTSmapiLoginTokens(SystemClock, "test-secret", "1h");
const validToken = smapiAuthTokens.issue("service-token-1");
tokenStore.set("token1", validToken);
const deletedCount = tokenStore.cleanupExpired(smapiAuthTokens);
expect(deletedCount).toBe(0);
expect(tokenStore.get("token1")).toBeDefined();
});
});
describe("JSON Migration", () => {
it("should migrate tokens from JSON file", () => {
const jsonTokens = {
token1: { token: "jwt1", key: "key1" },
token2: { token: "jwt2", key: "key2" },
token3: { token: "jwt3", key: "key3" },
};
fs.writeFileSync(testJsonPath, JSON.stringify(jsonTokens, null, 2), "utf8");
const migratedCount = tokenStore.migrateFromJSON(testJsonPath);
expect(migratedCount).toBe(3);
expect(tokenStore.get("token1")).toEqual(jsonTokens.token1);
expect(tokenStore.get("token2")).toEqual(jsonTokens.token2);
expect(tokenStore.get("token3")).toEqual(jsonTokens.token3);
});
it("should create backup of original JSON file", () => {
const jsonTokens = {
token1: { token: "jwt1", key: "key1" },
};
fs.writeFileSync(testJsonPath, JSON.stringify(jsonTokens, null, 2), "utf8");
tokenStore.migrateFromJSON(testJsonPath);
expect(fs.existsSync(`${testJsonPath}.bak`)).toBe(true);
expect(fs.existsSync(testJsonPath)).toBe(false);
});
it("should return 0 when JSON file does not exist", () => {
const migratedCount = tokenStore.migrateFromJSON(testJsonPath);
expect(migratedCount).toBe(0);
});
it("should handle empty JSON file", () => {
fs.writeFileSync(testJsonPath, JSON.stringify({}), "utf8");
const migratedCount = tokenStore.migrateFromJSON(testJsonPath);
expect(migratedCount).toBe(0);
});
});
describe("Persistence", () => {
it("should persist tokens across instances", () => {
const testToken: SmapiToken = { token: "jwt1", key: "key1" };
tokenStore.set("token1", testToken);
tokenStore.close();
// Create new instance with same database
const newStore = new SQLiteSmapiTokenStore(testDbPath);
const retrieved = newStore.get("token1");
expect(retrieved).toEqual(testToken);
newStore.close();
});
});
describe("Close", () => {
it("should close database connection without error", () => {
expect(() => tokenStore.close()).not.toThrow();
});
it("should handle multiple close calls gracefully", () => {
tokenStore.close();
// Second close should not throw
expect(() => tokenStore.close()).not.toThrow();
});
});
});

View File

@@ -1,6 +1,12 @@
<% layout('./layout', { title: it.lang("failure") }) %> <% layout('./layout', { title: it.lang("failure") }) %>
<div id="content"> <div id="content">
<h1 class="failure"><%= it.message %></h1> <div class="message-container">
<h1 class="cause"><%= it.cause || "" %></h1> <div class="logo">✗</div>
<h1><%= it.message %></h1>
<% if (it.cause) { %>
<p style="color: #dc3545; margin-top: 15px;"><%= it.cause %></p>
<% } %>
<p style="color: #dc3545; font-weight: 600; margin-top: 10px;"><%= it.lang("failure") %></p>
</div>
</div> </div>

View File

@@ -1,45 +1,61 @@
<% layout('./layout') %> <% layout('./layout') %>
<div id="content"> <div id="content" class="index-content">
<div width="100%" style="text-align:right;color:grey"><%= it.version %></div> <div style="text-align:right;color:#999;font-size:0.85rem;margin-bottom:20px"><%= it.version %></div>
<h1><%= it.bonobService.name %> (<%= it.bonobService.sid %>)</h1> <div class="logo">🎵</div>
<h3><%= it.lang("expectedConfig") %></h3> <h1><%= it.bonobService.name %></h1>
<div><%= JSON.stringify(it.bonobService) %></div> <p style="color:#999;margin-bottom:30px">Service ID: <%= it.bonobService.sid %></p>
<br/>
<% if(it.devices.length > 0) { %> <% if(it.devices.length > 0) { %>
<form action="<%= it.createRegistrationRoute %>" method="POST"> <form action="<%= it.createRegistrationRoute %>" method="POST" style="margin-bottom:30px">
<input type="submit" value="<%= it.lang("register") %>"> <input type="submit" value="<%= it.lang("register") %>" id="submit">
</form> </form>
<br/>
<% } else { %> <% } else { %>
<h3><%= it.lang("noSonosDevices") %></h3> <p style="color:#dc3545;font-weight:600;margin:30px 0"><%= it.lang("noSonosDevices") %></p>
<br/>
<% } %> <% } %>
<% if(it.registeredBonobService) { %> <% if(it.registeredBonobService) { %>
<h3><%= it.lang("existingServiceConfig") %></h3> <div style="margin:20px 0;padding:15px;background:#f8f9fa;border-radius:8px">
<div><%= JSON.stringify(it.registeredBonobService) %></div> <h3 style="font-size:1.1rem;margin-bottom:10px;color:#667eea"><%= it.lang("existingServiceConfig") %></h3>
<pre style="font-size:0.85rem;text-align:left;overflow-x:auto"><%= JSON.stringify(it.registeredBonobService, null, 2) %></pre>
</div>
<% } else { %> <% } else { %>
<h3><%= it.lang("noExistingServiceRegistration") %></h3> <p style="color:#999;margin:20px 0"><%= it.lang("noExistingServiceRegistration") %></p>
<% } %> <% } %>
<% if(it.registeredBonobService) { %> <% if(it.registeredBonobService) { %>
<br/> <form action="<%= it.removeRegistrationRoute %>" method="POST" style="margin:20px 0">
<form action="<%= it.removeRegistrationRoute %>" method="POST"> <input type="submit" value="<%= it.lang("removeRegistration") %>" id="submit" style="background:#dc3545">
<input type="submit" value="<%= it.lang("removeRegistration") %>">
</form> </form>
<% } %> <% } %>
<br/> <div style="margin-top:40px;padding-top:30px;border-top:2px solid #e0e0e0">
<h2><%= it.lang("devices") %> (<%= it.devices.length %>)</h2> <h2 style="font-size:1.5rem;margin-bottom:15px"><%= it.lang("devices") %> (<%= it.devices.length %>)</h2>
<ul> <% if(it.devices.length > 0) { %>
<ul style="list-style:none;text-align:left;padding:0">
<% it.devices.forEach(function(d){ %> <% it.devices.forEach(function(d){ %>
<li><%= d.name %> (<%= d.ip %>:<%= d.port %>)</li> <li style="padding:10px;margin:5px 0;background:#f8f9fa;border-radius:6px">
<strong><%= d.name %></strong> <span style="color:#999">(<%= d.ip %>:<%= d.port %>)</span>
</li>
<% }) %> <% }) %>
</ul> </ul>
<h2><%= it.lang("services") %> (<%= it.services.length %>)</h2> <% } else { %>
<ul> <p style="color:#999">No devices found</p>
<% } %>
</div>
<div style="margin-top:30px">
<h2 style="font-size:1.5rem;margin-bottom:15px"><%= it.lang("services") %> (<%= it.services.length %>)</h2>
<% if(it.services.length > 0) { %>
<ul style="list-style:none;text-align:left;padding:0">
<% it.services.forEach(function(s){ %> <% it.services.forEach(function(s){ %>
<li><%= s.name %> (<%= s.sid %>)</li> <li style="padding:10px;margin:5px 0;background:#f8f9fa;border-radius:6px">
<strong><%= s.name %></strong> <span style="color:#999">(SID: <%= s.sid %>)</span>
</li>
<% }) %> <% }) %>
</ul> </ul>
<% } else { %>
<p style="color:#999">No services registered</p>
<% } %>
</div>
</div> </div>

View File

@@ -1,46 +1,189 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= it.title || "bonob" %></title> <title><%= it.title || "bonob" %></title>
<style> <style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body { body {
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
} }
div#content { div#content {
margin: auto; background: white;
width: 60%; border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 50px 40px;
max-width: 450px;
width: 100%;
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
h1 { h1 {
font-size: 700%; font-size: 2.5rem;
font-weight: 700;
color: #333;
text-align: center;
margin-bottom: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
form {
display: flex;
flex-direction: column;
gap: 25px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
} }
label { label {
font-size: 300%; font-size: 0.95rem;
font-weight: 600;
color: #555;
margin-left: 5px;
} }
input { input[type="text"],
font-size: 300%; input[type="password"] {
width: 80%; width: 100%;
padding: 15px 20px;
font-size: 1rem;
border: 2px solid #e0e0e0;
border-radius: 12px;
transition: all 0.3s ease;
background: #f8f9fa;
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
transform: translateY(-2px);
} }
input#submit { input#submit {
margin-top: 100px width: 100%;
padding: 16px;
font-size: 1.1rem;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 10px;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
} }
.one-word-per-line { input#submit:hover {
word-spacing: 100000px; transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
} }
.login{ input#submit:active {
width: min-intrinsic; transform: translateY(0);
width: -webkit-min-content; }
width: -moz-min-content;
width: min-content; .logo {
display: table-caption; text-align: center;
display: -ms-grid; margin-bottom: 20px;
-ms-grid-columns: min-content; font-size: 3rem;
opacity: 0.9;
}
/* Success and failure page styles */
.message-container {
text-align: center;
padding: 20px 0;
}
.message-container h1 {
font-size: 2rem;
margin-bottom: 20px;
}
.message-container p {
font-size: 1.1rem;
color: #666;
line-height: 1.6;
}
/* Index page styles */
.index-content {
text-align: center;
}
.index-content h1 {
font-size: 2.5rem;
margin-bottom: 20px;
}
.index-content p {
font-size: 1.1rem;
color: #666;
line-height: 1.8;
margin-bottom: 15px;
}
.index-content a {
color: #667eea;
text-decoration: none;
font-weight: 600;
transition: color 0.3s ease;
}
.index-content a:hover {
color: #764ba2;
text-decoration: underline;
}
/* Responsive design */
@media (max-width: 500px) {
div#content {
padding: 40px 30px;
}
h1 {
font-size: 2rem;
}
input[type="text"],
input[type="password"] {
padding: 12px 16px;
}
} }
</style> </style>
</head> </head>

View File

@@ -1,12 +1,17 @@
<% layout('./layout', { title: it.lang("login") }) %> <% layout('./layout', { title: it.lang("login") }) %>
<div id="content"> <div id="content">
<h1 class="login one-word-per-line"><%= it.lang("logInToBonob") %></h1> <div class="logo">🎵</div>
<h1><%= it.lang("logInToBonob") %></h1>
<form action="<%= it.loginRoute %>" method="POST"> <form action="<%= it.loginRoute %>" method="POST">
<label for="username"><%= it.lang("username") %>:</label><br> <div class="form-group">
<input type="text" id="username" name="username"><br><br> <label for="username"><%= it.lang("username") %></label>
<label for="password"><%= it.lang("password") %>:</label><br> <input type="text" id="username" name="username" required autofocus>
<input type="password" id="password" name="password"><br> </div>
<div class="form-group">
<label for="password"><%= it.lang("password") %></label>
<input type="password" id="password" name="password" required>
</div>
<input type="hidden" name="linkCode" value="<%= it.linkCode %>"> <input type="hidden" name="linkCode" value="<%= it.linkCode %>">
<input type="submit" value="<%= it.lang("login") %>" id="submit"> <input type="submit" value="<%= it.lang("login") %>" id="submit">
</form> </form>

View File

@@ -1,5 +1,9 @@
<% layout('./layout', { title: it.lang("success") }) %> <% layout('./layout', { title: it.lang("success") }) %>
<div id="content"> <div id="content">
<h1 class="success"><%= it.message %></h1> <div class="message-container">
<div class="logo">✓</div>
<h1><%= it.message %></h1>
<p style="color: #28a745; font-weight: 600; margin-top: 10px;"><%= it.lang("success") %></p>
</div>
</div> </div>