diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2332c95 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,187 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +bonob is a Sonos SMAPI (Sonos Music API) implementation that bridges Subsonic API clones (like Navidrome and Gonic) with Sonos devices. It acts as a middleware service that translates between the Subsonic API and Sonos's proprietary music service protocol, allowing users to stream their personal music libraries to Sonos speakers. + +## Development Commands + +### Building and Running +```bash +# Build TypeScript to JavaScript +npm run build + +# Development mode with auto-reload (requires environment variables) +npm run dev +# OR with auto-registration +npm run devr + +# Register bonob service with Sonos devices +npm run register-dev +``` + +### Testing +```bash +# Run all tests +npm test + +# Run tests in watch mode +npm run testw + +# Set custom test timeout (default: 5000ms) +JEST_TIMEOUT=10000 npm test +``` + +### Environment Variables for Development +When running locally, you need to set several environment variables: +- `BNB_DEV_HOST_IP`: Your machine's IP address (so Sonos can reach bonob) +- `BNB_DEV_SONOS_DEVICE_IP`: IP address of a Sonos device for discovery +- `BNB_DEV_SUBSONIC_URL`: URL of your Subsonic API server (e.g., Navidrome) + +## Architecture + +### Core Components + +**`src/app.ts`** - Application entry point +- Reads configuration from environment variables +- Initializes all services (Subsonic, Sonos, authentication) +- Wires together the Express server with appropriate dependencies +- Handles SIGTERM for graceful shutdown + +**`src/server.ts`** - Express HTTP server +- Serves web UI for service registration and login +- Handles music streaming (`/stream/track/:id`) +- Generates icons and cover art (`/icon/...`, `/art/...`) +- Serves Sonos-specific XML files (strings, presentation map) +- Binds SOAP service for Sonos SMAPI communication + +**`src/smapi.ts`** - Sonos SMAPI SOAP implementation (1200+ lines) +- Implements the Sonos Music API Protocol via SOAP/XML +- Core operations: `getMetadata`, `getMediaURI`, `search`, `getExtendedMetadata` +- Handles authentication flow with link codes and device auth tokens +- Manages token refresh and session management +- Maps music library concepts to Sonos browse hierarchy + +**`src/subsonic.ts`** - Subsonic API client +- Implements `MusicService` and `MusicLibrary` interfaces +- Handles authentication with Subsonic servers using token-based auth +- Translates between Subsonic data models and bonob's domain types +- Supports custom player configurations for transcoding +- Special handling for Navidrome (bearer token authentication) +- Implements artist image fetching with optional caching + +**`src/music_service.ts`** - Core domain types and interfaces +- Defines `MusicService` interface (auth and login) +- Defines `MusicLibrary` interface (browsing, search, streaming, rating, scrobbling) +- Domain types: `Artist`, `Album`, `Track`, `Playlist`, `Genre`, `Rating`, etc. +- Uses `fp-ts` for functional programming patterns (`TaskEither`, `Option`, `Either`) + +**`src/sonos.ts`** - Sonos device discovery and registration +- Discovers Sonos devices on the network using SSDP/UPnP +- Registers/unregisters bonob as a music service with Sonos systems +- Supports both auto-discovery and seed-host based discovery +- Uses `@svrooij/sonos` library for device communication + +**`src/smapi_auth.ts`** - Authentication token management +- Implements JWT-based SMAPI tokens (token + key pairs) +- Handles token verification and expiry +- Token refresh flow using `fp-ts` `TaskEither` + +**`src/config.ts`** - Configuration management +- Reads and validates environment variables (all prefixed with `BNB_`) +- Legacy environment variable support (BONOB_ prefix) +- Type-safe configuration with defaults + +### Key Abstractions + +**BUrn (Bonob URN)** - Resource identifier system (`src/burn.ts`) +- Format: `{ system: string, resource: string }` +- Systems: `subsonic` (for cover art), `external` (for URLs like Spotify images) +- Used for abstracting art/image sources across different backends + +**URL Builder** (`src/url_builder.ts`) +- Wraps URL manipulation with a builder pattern +- Handles context path for reverse proxy deployments +- Used throughout for generating URLs that Sonos devices can access + +**Custom Players** (`src/subsonic.ts`) +- Allows mime-type specific transcoding configurations +- Maps source mime types to transcoded types +- Creates custom "client" names in Subsonic (e.g., "bonob+audio/flac") +- Example: `BNB_SUBSONIC_CUSTOM_CLIENTS="audio/flac>audio/mp3"` + +### Data Flow + +1. **Sonos App Request** → SOAP endpoint (`/ws/sonos`) +2. **SOAP Service** → Verifies auth token, calls `MusicLibrary` methods +3. **MusicLibrary** → Makes Subsonic API calls, transforms data +4. **SOAP Response** → Returns XML formatted for Sonos + +For streaming: +1. **Sonos Device** → `GET /stream/track/:id` with custom headers (bnbt, bnbk) +2. **Stream Handler** → Verifies token, calls `MusicLibrary.stream()` +3. **Subsonic Stream** → Proxies audio with proper mime-type handling +4. **Response** → Streams audio to Sonos, reports "now playing" + +### Icon System (`src/icon.ts`) +- SVG-based icon generation with dynamic colors +- Supports foreground/background color customization via `BNB_ICON_FOREGROUND_COLOR` and `BNB_ICON_BACKGROUND_COLOR` +- Genre-specific icons +- Text overlay support (e.g., year icons like "1984") +- Holiday/festival decorations (auto-applied based on date) +- Legacy mode: renders to 80x80 PNG for older Sonos systems + +### Authentication Flow +1. Sonos app requests link code via `getAppLink()` +2. User visits login URL with link code +3. User enters Subsonic credentials +4. bonob validates with Subsonic, generates service token +5. bonob associates link code with service token +6. Sonos polls `getDeviceAuthToken()` with link code +7. bonob returns SMAPI token (JWT) to Sonos +8. Subsequent requests use SMAPI token, which maps to service token + +### Testing Philosophy +- Jest with ts-jest preset +- In-memory implementations for `LinkCodes`, `APITokens` for testing +- Mocking with `ts-mockito` +- Test helpers in `tests/` directory +- Console.log suppressed in tests (see `tests/setup.js`) + +## Common Patterns + +### Error Handling +- Use `fp-ts` `TaskEither` for async operations that can fail with auth errors +- SOAP faults for Sonos-specific errors (see SMAPI_FAULT_* constants) +- Promise-based error handling with `.catch()` for most async operations + +### Type Safety +- Strict TypeScript (`strict: true`, `noImplicitAny: true`, `noUncheckedIndexedAccess: true`) +- Extensive use of discriminated unions +- Interface-based design for pluggable services + +### Logging +- Winston-based logger (`src/logger.ts`) +- Log level controlled by `BNB_LOG_LEVEL` +- Request logging optional via `BNB_SERVER_LOG_REQUESTS` + +### Functional Programming +- Heavy use of `fp-ts` for `Option`, `Either`, `TaskEither` +- Pipe-based composition (`pipe(data, fn1, fn2, ...)`) +- Immutable data transformations + +## File Organization +- `src/` - TypeScript source code +- `tests/` - Jest test files (mirrors src/ structure) +- `build/` - Compiled JavaScript (gitignored) +- `web/` - HTML templates (Eta templating) and static assets +- `typings/` - Custom TypeScript definitions + +## Important Constraints +- bonob must be accessible from Sonos devices at `BNB_URL` +- `BNB_URL` cannot contain "localhost" (validation error) +- Sonos requires specific XML formats (SMAPI WSDL v1.19.6) +- Streaming must handle HTTP range requests for seek functionality +- Token lifetime (`BNB_AUTH_TIMEOUT`) should be less than Subsonic session timeout diff --git a/log.txt b/log.txt new file mode 100644 index 0000000..dacf3f3 --- /dev/null +++ b/log.txt @@ -0,0 +1,62 @@ +{"level":"info","message":"Starting bonob with config {\"port\":8200,\"bonobUrl\":{\"url\":\"https://bonob.famkulhanek.com/\"},\"secret\":\"*******\",\"authTimeout\":\"1h\",\"icons\":{\"foregroundColor\":\"black\",\"backgroundColor\":\"#65d7f4\"},\"logRequests\":true,\"sonos\":{\"serviceName\":\"Kulhanek\",\"discovery\":{\"enabled\":false},\"autoRegister\":false,\"sid\":114316248},\"subsonic\":{\"url\":{\"url\":\"https://music.famkulhanek.com/\"}},\"scrobbleTracks\":true,\"reportNowPlaying\":true}","service":"bonob","timestamp":"2025-10-16 09:45:13"} +{"level":"info","message":"Listening on 8200 available @ https://bonob.famkulhanek.com/","service":"bonob","timestamp":"2025-10-16 09:45:13"} +{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:46"} +{"level":"debug","message":{"data":"00-00-00-00-00-00:0SonosSonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b1iPhone14,3Version 26.0.1 (Build 23A355)ICRU_iPhone14,3sonos-2://x-callback-url/addAccount?state=intId%3Dcom%2Efamkulhanek%2Emusic","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:46"} +{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:46"} +{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:46"} +{"level":"debug","message":{"data":"AppLinkMessagehttps://bonob.famkulhanek.com/login?linkCode=42504097-231c-4abb-a878-cecffcc0666f42504097-231c-4abb-a878-cecffcc0666ffalse","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:46"} +::ffff:10.88.0.1 - - [16/Oct/2025:07:45:46 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070" +{"level":"debug","message":"/login (req[accept-language]=de-DE,de;q=0.9)","service":"bonob","timestamp":"2025-10-16 09:45:49"} +::ffff:10.88.0.1 - - [16/Oct/2025:07:45:49 +0000] "GET /login?linkCode=42504097-231c-4abb-a878-cecffcc0666f HTTP/1.1" 200 1240 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1" +{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:51"} +{"level":"debug","message":{"data":"00-00-00-00-00-00:0SonosSonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b142504097-231c-4abb-a878-cecffcc0666f","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:51"} +{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:51"} +{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:51"} +{"level":"info","message":"Client not linked, awaiting user to associate account with link code by logging in.","service":"bonob","timestamp":"2025-10-16 09:45:51"} +{"level":"debug","message":{"data":"Client.NOT_LINKED_RETRYLink Code not found yet, sonos app will keep polling until you log in to bonobNOT_LINKED_RETRY5","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:51"} +::ffff:10.88.0.1 - - [16/Oct/2025:07:45:51 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070" +{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:57"} +{"level":"debug","message":{"data":"00-00-00-00-00-00:0SonosSonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b142504097-231c-4abb-a878-cecffcc0666f","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:57"} +{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:57"} +{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:45:57"} +{"level":"info","message":"Client not linked, awaiting user to associate account with link code by logging in.","service":"bonob","timestamp":"2025-10-16 09:45:57"} +{"level":"debug","message":{"data":"Client.NOT_LINKED_RETRYLink Code not found yet, sonos app will keep polling until you log in to bonobNOT_LINKED_RETRY5","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:45:57"} +::ffff:10.88.0.1 - - [16/Oct/2025:07:45:57 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070" +{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:02"} +{"level":"debug","message":{"data":"00-00-00-00-00-00:0SonosSonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b142504097-231c-4abb-a878-cecffcc0666f","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:02"} +{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:02"} +{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:02"} +{"level":"info","message":"Client not linked, awaiting user to associate account with link code by logging in.","service":"bonob","timestamp":"2025-10-16 09:46:02"} +{"level":"debug","message":{"data":"Client.NOT_LINKED_RETRYLink Code not found yet, sonos app will keep polling until you log in to bonobNOT_LINKED_RETRY5","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:02"} +::ffff:10.88.0.1 - - [16/Oct/2025:07:46:02 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070" +{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:07"} +{"level":"debug","message":{"data":"00-00-00-00-00-00:0SonosSonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b142504097-231c-4abb-a878-cecffcc0666f","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:07"} +{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:07"} +{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:07"} +{"level":"info","message":"Client not linked, awaiting user to associate account with link code by logging in.","service":"bonob","timestamp":"2025-10-16 09:46:07"} +{"level":"debug","message":{"data":"Client.NOT_LINKED_RETRYLink Code not found yet, sonos app will keep polling until you log in to bonobNOT_LINKED_RETRY5","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:07"} +::ffff:10.88.0.1 - - [16/Oct/2025:07:46:07 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070" +{"level":"debug","message":"/login (req[accept-language]=de-DE,de;q=0.9)","service":"bonob","timestamp":"2025-10-16 09:46:08"} +::ffff:10.88.0.1 - - [16/Oct/2025:07:46:09 +0000] "POST /login HTTP/1.1" 200 817 "https://bonob.famkulhanek.com/login?linkCode=42504097-231c-4abb-a878-cecffcc0666f" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Mobile/15E148 Safari/604.1" +{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:13"} +{"level":"debug","message":{"data":"00-00-00-00-00-00:0SonosSonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b142504097-231c-4abb-a878-cecffcc0666f","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:13"} +{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:13"} +{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:13"} +{"level":"debug","message":"Adding token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0 {\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0\",\"key\":\"64a930fd-475d-4285-85b1-185f1d358453\"}","service":"bonob","timestamp":"2025-10-16 09:46:13"} +{"level":"debug","message":{"data":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE064a930fd-475d-4285-85b1-185f1d358453wolfgang86c12ba6737d0873c383445f01db4c6c691579efc0110dd4537bc34b7f5e3e6d","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:13"} +::ffff:10.88.0.1 - - [16/Oct/2025:07:46:13 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070" +{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:17"} +{"level":"debug","message":{"data":"00-00-00-00-00-00:0SonoseyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE064a930fd-475d-4285-85b1-185f1d358453Sonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b1addAccount","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:17"} +{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:17"} +{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:17"} +{"level":"info","message":"Sonos reportAccountAction: {\"type\":\"addAccount\"} Headers: {\"host\":\"bonob.famkulhanek.com\",\"user-agent\":\"Linux UPnP/1.0 Sonos/91.0-70070\",\"content-length\":\"1242\",\"accept\":\"text/xml, text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2\",\"accept-encoding\":\"gzip\",\"cache-control\":\"no-cache\",\"content-type\":\"text/xml; charset=utf-8\",\"pragma\":\"no-cache\",\"soapaction\":\"\\\"http://www.sonos.com/Services/1.1#reportAccountAction\\\"\",\"x-forwarded-for\":\"44.205.206.64\",\"x-forwarded-host\":\"bonob.famkulhanek.com\",\"x-forwarded-port\":\"443\",\"x-forwarded-proto\":\"https\",\"x-forwarded-server\":\"ea7d24592c56\",\"x-real-ip\":\"44.205.206.64\"}","service":"bonob","timestamp":"2025-10-16 09:46:17"} +{"level":"debug","message":{"data":"","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:17"} +::ffff:10.88.0.1 - - [16/Oct/2025:07:46:17 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "Linux UPnP/1.0 Sonos/91.0-70070" +{"level":"debug","message":{"data":"Handling POST on /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:23"} +{"level":"debug","message":{"data":"SonoseyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0Sonos_wG6xlFpEtv2adIteHFXs7nRgw1_e81df8b1root0100","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:23"} +{"level":"debug","message":{"data":"Attempting to bind to /ws/sonos","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:23"} +{"level":"debug","message":{"data":"Trying SonosSoap from path /Test/TestService.php","level":"info"},"service":"bonob","timestamp":"2025-10-16 09:46:23"} +{"level":"debug","message":"getCredentialsForToken called with: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0","service":"bonob","timestamp":"2025-10-16 09:46:23"} +{"level":"debug","message":"Current tokens: {\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXJ2aWNlVG9rZW4iOiJleUoxYzJWeWJtRnRaU0k2SW5kdmJHWm5ZVzVuSWl3aWNHRnpjM2R2Y21RaU9pSlRkSEpoYm1kc1pTMUNZV2RtZFd3d0xVeGxaMmRwYm1keklpd2lZbVZoY21WeUlqb2laWGxLYUdKSFkybFBhVXBKVlhwSk1VNXBTWE5KYmxJMVkwTkpOa2xyY0ZoV1EwbzVMbVY1U21oYVJ6QnBUMjFhYUdKSVRteE1RMHBzWlVoQmFVOXFSVE5PYWtFelRucE5NVTVxYTNOSmJXeG9aRU5KTmsxVVl6Sk5SRmwzVFVSak1rOVRkMmxoV0U1NlNXcHZhVlJyVVdsTVEwcDZaRmRKYVU5cFNqTmlNbmh0V2pKR2RWcDVTWE5KYmxad1drTkpOa2xxVWxKU1YxbDVVakpTZVdOV1VsSmpNVkp0WTFVMWVXTnFTVEZTVlRocFpsRXVhbVZ2VkdGcFMxRkdMVXhLYVVvdFNtVnlUR1owZVcxVWRVbEhWRTVUZWtFNVFWVkZhM2hOUzA5RFJTSXNJblI1Y0dVaU9pSnVZWFpwWkhKdmJXVWlmUT09IiwiaWF0IjoxNzYwNjAwNzczLCJleHAiOjE3NjA2MDQzNzN9.CizS-PP_uS-wLSnBm7fiIXGg_HXHcaQQHCtLnXsMdE0\",\"key\":\"64a930fd-475d-4285-85b1-185f1d358453\"}}","service":"bonob","timestamp":"2025-10-16 09:46:23"} +{"level":"debug","message":{"data":"Client.LoginUnauthorizedFailed to authenticate, try Re-Authorising your account in the sonos app","level":"debug"},"service":"bonob","timestamp":"2025-10-16 09:46:23"} +::ffff:10.88.0.1 - - [16/Oct/2025:07:46:23 +0000] "POST /ws/sonos HTTP/1.1" 200 - "-" "com.sonos.SonosController2/80.30 iPhone14,3 iOS/26.0.1 CFNetwork/1.0 Darwin/25.0.0 (ICRU_iPhone14,3) (Sonos/Universal-Content-Service 1.1.998)" diff --git a/package-lock.json b/package-lock.json index a44dfee..a1b43e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3024,31 +3024,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", diff --git a/src/app.ts b/src/app.ts index da16958..4ddf669 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,6 +18,7 @@ import sonos, { bonobService } from "./sonos"; import { MusicService } from "./music_service"; import { SystemClock } from "./clock"; import { JWTSmapiLoginTokens } from "./smapi_auth"; +import { FileSmapiTokenStore } from "./smapi_token_store"; const config = readConfig(); const clock = SystemClock; @@ -95,7 +96,8 @@ const app = server( logRequests: config.logRequests, version, smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout), - externalImageResolver: artistImageFetcher + externalImageResolver: artistImageFetcher, + smapiTokenStore: new FileSmapiTokenStore("/config/tokens.json") } ); diff --git a/src/server.ts b/src/server.ts index 8a90f37..d75d15c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -39,6 +39,7 @@ import { JWTSmapiLoginTokens, SmapiAuthTokens, } from "./smapi_auth"; +import { SmapiTokenStore, InMemorySmapiTokenStore } from "./smapi_token_store"; export const BONOB_ACCESS_TOKEN_HEADER = "bat"; @@ -92,6 +93,7 @@ export type ServerOpts = { version: string; smapiAuthTokens: SmapiAuthTokens; externalImageResolver: ImageFetcher; + smapiTokenStore: SmapiTokenStore; }; const DEFAULT_SERVER_OPTS: ServerOpts = { @@ -108,6 +110,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = { "1m" ), externalImageResolver: axiosImageFetcher, + smapiTokenStore: new InMemorySmapiTokenStore(), }; function server( @@ -607,7 +610,8 @@ function server( apiTokens, clock, i8n, - serverOpts.smapiAuthTokens + serverOpts.smapiAuthTokens, + serverOpts.smapiTokenStore ); if (serverOpts.applyContextPath) { diff --git a/src/smapi.ts b/src/smapi.ts index 3b21488..f8db8e2 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -40,6 +40,7 @@ import { } from "./smapi_auth"; import { InvalidTokenError } from "./smapi_auth"; import { IncomingHttpHeaders } from "http2"; +import { SmapiTokenStore } from "./smapi_token_store"; export const LOGIN_ROUTE = "/login"; export const CREATE_REGISTRATION_ROUTE = "/registration/add"; @@ -164,20 +165,20 @@ class SonosSoap { bonobUrl: URLBuilder; smapiAuthTokens: SmapiAuthTokens; clock: Clock; - tokens: {[tokenKey:string]:SmapiToken}; + tokenStore: SmapiTokenStore; constructor( bonobUrl: URLBuilder, linkCodes: LinkCodes, smapiAuthTokens: SmapiAuthTokens, - clock: Clock - + clock: Clock, + tokenStore: SmapiTokenStore ) { this.bonobUrl = bonobUrl; this.linkCodes = linkCodes; this.smapiAuthTokens = smapiAuthTokens; this.clock = clock; - this.tokens = {}; + this.tokenStore = tokenStore; } getAppLink(): GetAppLinkResult { @@ -244,17 +245,17 @@ class SonosSoap { }; } } - getCredentialsForToken(token: string): SmapiToken { + getCredentialsForToken(token: string): SmapiToken | undefined { logger.debug("getCredentialsForToken called with: " + token); - logger.debug("Current tokens: " + JSON.stringify(this.tokens)); - return this.tokens[token]!; + logger.debug("Current tokens: " + JSON.stringify(this.tokenStore.getAll())); + return this.tokenStore.get(token); } associateCredentialsForToken(token: string, fullSmapiToken: SmapiToken, oldToken?:string) { logger.debug("Adding token: " + token + " " + JSON.stringify(fullSmapiToken)); if(oldToken) { - delete this.tokens[oldToken]; + this.tokenStore.delete(oldToken); } - this.tokens[token] = fullSmapiToken; + this.tokenStore.set(token, fullSmapiToken); } } @@ -403,9 +404,10 @@ function bindSmapiSoapServiceToExpress( apiKeys: APITokens, clock: Clock, i8n: I8N, - smapiAuthTokens: SmapiAuthTokens + smapiAuthTokens: SmapiAuthTokens, + tokenStore: SmapiTokenStore ) { - const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock); + const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock, tokenStore); const urlWithToken = (accessToken: string) => bonobUrl.append({ diff --git a/src/smapi_token_store.ts b/src/smapi_token_store.ts new file mode 100644 index 0000000..95b5cb4 --- /dev/null +++ b/src/smapi_token_store.ts @@ -0,0 +1,96 @@ +import fs from "fs"; +import path from "path"; +import logger from "./logger"; +import { SmapiToken } from "./smapi_auth"; + +export interface SmapiTokenStore { + get(token: string): SmapiToken | undefined; + set(token: string, fullSmapiToken: SmapiToken): void; + delete(token: string): void; + getAll(): { [tokenKey: string]: SmapiToken }; +} + +export class InMemorySmapiTokenStore implements SmapiTokenStore { + private tokens: { [tokenKey: string]: SmapiToken } = {}; + + get(token: string): SmapiToken | undefined { + return this.tokens[token]; + } + + set(token: string, fullSmapiToken: SmapiToken): void { + this.tokens[token] = fullSmapiToken; + } + + delete(token: string): void { + delete this.tokens[token]; + } + + getAll(): { [tokenKey: string]: SmapiToken } { + return this.tokens; + } +} + +export class FileSmapiTokenStore implements SmapiTokenStore { + private tokens: { [tokenKey: string]: SmapiToken } = {}; + private readonly filePath: string; + + constructor(filePath: string) { + this.filePath = filePath; + this.loadFromFile(); + } + + private loadFromFile(): void { + try { + // Ensure the directory exists + const dir = path.dirname(this.filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + logger.info(`Created token storage directory: ${dir}`); + } + + // Load existing tokens if file exists + if (fs.existsSync(this.filePath)) { + const data = fs.readFileSync(this.filePath, "utf8"); + this.tokens = JSON.parse(data); + logger.info( + `Loaded ${Object.keys(this.tokens).length} token(s) from ${this.filePath}` + ); + } else { + logger.info(`No existing token file found at ${this.filePath}, starting fresh`); + this.tokens = {}; + this.saveToFile(); + } + } catch (error) { + logger.error(`Failed to load tokens from ${this.filePath}`, { error }); + this.tokens = {}; + } + } + + private saveToFile(): void { + try { + const data = JSON.stringify(this.tokens, null, 2); + fs.writeFileSync(this.filePath, data, "utf8"); + logger.debug(`Saved ${Object.keys(this.tokens).length} token(s) to ${this.filePath}`); + } catch (error) { + logger.error(`Failed to save tokens to ${this.filePath}`, { error }); + } + } + + get(token: string): SmapiToken | undefined { + return this.tokens[token]; + } + + set(token: string, fullSmapiToken: SmapiToken): void { + this.tokens[token] = fullSmapiToken; + this.saveToFile(); + } + + delete(token: string): void { + delete this.tokens[token]; + this.saveToFile(); + } + + getAll(): { [tokenKey: string]: SmapiToken } { + return this.tokens; + } +}