mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
@@ -0,0 +1,33 @@
|
||||
# .dockerignore
|
||||
|
||||
# Exclude large firmware files and archives
|
||||
firmware/
|
||||
data/
|
||||
|
||||
# Exclude local build artifacts
|
||||
build/
|
||||
soundtouch-cli
|
||||
soundtouch-service
|
||||
|
||||
# Exclude Go specific files that aren't needed for build context
|
||||
# (go.mod and go.sum ARE needed, but other local stuff isn't)
|
||||
.cache/
|
||||
vendor/
|
||||
|
||||
# Exclude IDE and system files
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
|
||||
# Exclude Git history
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Exclude documentation and other non-essential files for the binary build
|
||||
docs/
|
||||
examples/
|
||||
scripts/
|
||||
CONTRIBUTING.md
|
||||
CODE_OF_CONDUCT.md
|
||||
LICENSE
|
||||
README.md
|
||||
@@ -0,0 +1,5 @@
|
||||
# Files intentionally not linked in docs/SUMMARY.md.
|
||||
# Paths are relative to the docs/ directory.
|
||||
# Lines starting with # and blank lines are ignored.
|
||||
|
||||
#analysis/bose-soundtouch-community-tools.md
|
||||
@@ -0,0 +1,17 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.html]
|
||||
# HTML-specific formatting
|
||||
# Standardize on tag layout
|
||||
ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot
|
||||
ij_html_keep_blank_lines = 1
|
||||
ij_html_attribute_wrap = normal
|
||||
ij_html_space_inside_empty_tag = false
|
||||
@@ -1,6 +1,10 @@
|
||||
# Bose SoundTouch Configuration
|
||||
# Copy this file to .env and customize for your setup
|
||||
|
||||
# Docker/Service Settings
|
||||
SOUNDTOUCH_HOSTNAME=soundtouch.local
|
||||
SOUNDTOUCH_VERSION=latest
|
||||
|
||||
# Discovery Settings
|
||||
DISCOVERY_TIMEOUT=5s
|
||||
UPNP_ENABLED=true
|
||||
@@ -38,3 +42,20 @@ PREFERRED_DEVICES="Living Room@192.168.1.100:8090;Kitchen@192.168.1.101;192.168.
|
||||
# Alternative format examples:
|
||||
# PREFERRED_DEVICES="192.168.178.35;192.168.178.28"
|
||||
# PREFERRED_DEVICES="SoundTouch 10@192.168.178.35;SoundTouch 20@192.168.178.28"
|
||||
|
||||
# Spotify Integration
|
||||
# Create an app at https://developer.spotify.com/dashboard
|
||||
# SPOTIFY_CLIENT_ID=your_client_id
|
||||
# SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
# Auth confirmation url using GET, works in browsers
|
||||
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/callback
|
||||
# Auth confirmation url using POST, works with the ueberboese-app (https://github.com/julius-d/ueberboese-app)
|
||||
# SPOTIFY_REDIRECT_URI=https://your-server.example.com/mgmt/spotify/confirm
|
||||
|
||||
# Management API Authentication
|
||||
# Protects /mgmt/* endpoints (Spotify token access, account management)
|
||||
MGMT_USERNAME=admin
|
||||
MGMT_PASSWORD=change_me!
|
||||
|
||||
# External base URL (required when behind a reverse proxy for OAuth callbacks)
|
||||
# BASE_URL=https://your-server.example.com
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: 'bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- OS: [e.g. macOS 14.0, Windows 11, Ubuntu 22.04]
|
||||
- Go version: [e.g. 1.25.5]
|
||||
- Library version: [e.g. v1.0.0, commit hash if using main branch]
|
||||
- SoundTouch device model: [e.g. SoundTouch 10, SoundTouch 20]
|
||||
- Device firmware version: [if known]
|
||||
|
||||
**Command/Code that failed**
|
||||
```bash
|
||||
# If using CLI tool, provide the exact command
|
||||
soundtouch-cli --host 192.168.1.100 info get
|
||||
|
||||
# If using Go library, provide minimal code example
|
||||
```
|
||||
|
||||
**Error output**
|
||||
```
|
||||
Paste the complete error message here, including stack traces if available
|
||||
```
|
||||
|
||||
**Device Information (if applicable)**
|
||||
```xml
|
||||
<!-- If the issue is device-specific, include output from: -->
|
||||
<!-- soundtouch-cli --host YOUR_DEVICE_IP info get -->
|
||||
```
|
||||
|
||||
**Network Configuration**
|
||||
- Network setup: [e.g. home WiFi, corporate network, VPN]
|
||||
- Firewall/proxy: [any network restrictions]
|
||||
- Device connectivity: [how device connects to network - WiFi, Ethernet]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here. For example:
|
||||
- Does this happen consistently or intermittently?
|
||||
- Did this work in a previous version?
|
||||
- Are there any workarounds?
|
||||
- Any relevant log files or debug output
|
||||
|
||||
**Logs (if applicable)**
|
||||
```
|
||||
# Enable verbose logging with --verbose flag or debug environment variable
|
||||
# and paste relevant log output here
|
||||
```
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
---
|
||||
|
||||
**Checklist**
|
||||
- [ ] I have searched existing issues to avoid duplicates
|
||||
- [ ] I have tested with the latest version
|
||||
- [ ] I have included all relevant environment information
|
||||
- [ ] I have provided a minimal reproduction case
|
||||
- [ ] I have included complete error messages
|
||||
@@ -24,10 +24,10 @@ body:
|
||||
label: Go Version
|
||||
description: What version of Go are you using?
|
||||
options:
|
||||
- "1.25.5+"
|
||||
- "1.25"
|
||||
- "1.24"
|
||||
- "1.23"
|
||||
- "1.22"
|
||||
- "1.21"
|
||||
- "1.20"
|
||||
- "Other (please specify in description)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: Device compatibility report
|
||||
about: Report compatibility with a new SoundTouch device model
|
||||
title: 'Device Compatibility: [Device Model]'
|
||||
labels: 'compatibility, documentation'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Device Information**
|
||||
- **Model**: [e.g. SoundTouch 30, Wave SoundTouch IV, SoundTouch Portable]
|
||||
- **Model Number**: [e.g. 738102-2100, found on device label]
|
||||
- **Firmware Version**: [if known, from device settings or API response]
|
||||
- **Purchase Date**: [approximate, helps identify firmware generation]
|
||||
|
||||
**Testing Results**
|
||||
|
||||
### Basic Functionality
|
||||
- [ ] Device discovery (UPnP/mDNS)
|
||||
- [ ] Basic device info (`GET /info`)
|
||||
- [ ] Now playing status (`GET /now_playing`)
|
||||
- [ ] Media controls (play/pause/stop)
|
||||
- [ ] Volume control
|
||||
- [ ] Source listing (`GET /sources`)
|
||||
|
||||
### Advanced Features
|
||||
- [ ] Bass control (`GET/POST /bass`)
|
||||
- [ ] Balance control (`GET/POST /balance`) - if stereo device
|
||||
- [ ] Clock/time management (`GET/POST /clockTime`)
|
||||
- [ ] Network information (`GET /networkInfo`)
|
||||
- [ ] WebSocket events
|
||||
- [ ] Multiroom zones (master)
|
||||
- [ ] Multiroom zones (slave)
|
||||
|
||||
### Advanced Audio Controls (Professional/High-end Models)
|
||||
- [ ] DSP controls (`GET/POST /audiodspcontrols`)
|
||||
- [ ] Tone controls (`GET/POST /audioproducttonecontrols`)
|
||||
- [ ] Level controls (`GET/POST /audioproductlevelcontrols`)
|
||||
|
||||
### Known Issues
|
||||
List any features that don't work or behave unexpectedly:
|
||||
- Feature name: Description of issue
|
||||
- Command that fails: `soundtouch-cli command that doesn't work`
|
||||
|
||||
**Device Info Output**
|
||||
```xml
|
||||
<!-- Paste output from: soundtouch-cli --host YOUR_DEVICE_IP info get -->
|
||||
<!-- This helps us understand device capabilities and variants -->
|
||||
```
|
||||
|
||||
**Device Capabilities Output**
|
||||
```xml
|
||||
<!-- Paste output from: soundtouch-cli --host YOUR_DEVICE_IP capabilities -->
|
||||
<!-- This shows what features the device reports as available -->
|
||||
```
|
||||
|
||||
**Bass Capabilities (if supported)**
|
||||
```xml
|
||||
<!-- Paste output from: soundtouch-cli --host YOUR_DEVICE_IP bass capabilities -->
|
||||
<!-- Only if the device supports bass control -->
|
||||
```
|
||||
|
||||
**Available Sources**
|
||||
```xml
|
||||
<!-- Paste output from: soundtouch-cli --host YOUR_DEVICE_IP source list -->
|
||||
<!-- Shows what audio sources this device supports -->
|
||||
```
|
||||
|
||||
**Testing Commands Used**
|
||||
```bash
|
||||
# List the specific commands you used for testing
|
||||
soundtouch-cli --host 192.168.1.100 info get
|
||||
soundtouch-cli --host 192.168.1.100 play start
|
||||
# ... etc
|
||||
```
|
||||
|
||||
**Environment**
|
||||
- **OS**: [e.g. macOS 14.0, Windows 11, Ubuntu 22.04]
|
||||
- **Go version**: [e.g. 1.25.5]
|
||||
- **Library version**: [e.g. v1.0.0, commit hash]
|
||||
- **Network setup**: [home WiFi, corporate, etc.]
|
||||
|
||||
**Performance Notes**
|
||||
- Response times: [normal, slow, timeouts]
|
||||
- Specific timeouts: [any endpoints that timeout]
|
||||
- WebSocket stability: [connects reliably, frequent disconnects, etc.]
|
||||
|
||||
**Comparison with Tested Models**
|
||||
If you have experience with other SoundTouch models:
|
||||
- **Similar to**: [e.g. works like SoundTouch 20]
|
||||
- **Differences from**: [e.g. missing balance control compared to SoundTouch 30]
|
||||
|
||||
**Additional Notes**
|
||||
Any other observations about device behavior, quirks, or special considerations:
|
||||
- Does the device have unique features not seen in other models?
|
||||
- Are there any setup requirements or configuration notes?
|
||||
- Does it work differently in different network environments?
|
||||
|
||||
**Documentation Impact**
|
||||
- [ ] Update supported devices list
|
||||
- [ ] Add device-specific notes to documentation
|
||||
- [ ] Update compatibility matrix
|
||||
- [ ] Add to integration test suite
|
||||
|
||||
---
|
||||
|
||||
**Checklist**
|
||||
- [ ] I have tested basic functionality (info, play, volume)
|
||||
- [ ] I have tested advanced features available on this device
|
||||
- [ ] I have provided complete device information output
|
||||
- [ ] I have noted any issues or limitations
|
||||
- [ ] I have tested in a typical network environment
|
||||
- [ ] I understand this helps improve compatibility for all users
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Use case**
|
||||
Describe your specific use case and how this feature would benefit you and other users.
|
||||
|
||||
**SoundTouch API Support**
|
||||
- [ ] This feature is supported by the official SoundTouch API
|
||||
- [ ] This feature is NOT supported by the SoundTouch API (custom enhancement)
|
||||
- [ ] I'm not sure if this is supported by the SoundTouch API
|
||||
|
||||
**API Documentation Reference (if applicable)**
|
||||
If this feature is based on a SoundTouch API endpoint, please provide:
|
||||
- Endpoint URL: [e.g. GET /newendpoint]
|
||||
- Documentation reference: [page number or section in official API docs]
|
||||
- XML request/response examples: [if known]
|
||||
|
||||
**Implementation Details (optional)**
|
||||
If you have ideas about how this could be implemented:
|
||||
- Suggested package/module: [e.g. pkg/client, cmd/soundtouch-cli]
|
||||
- Method signatures: [if you have suggestions]
|
||||
- CLI commands: [if this affects the CLI tool]
|
||||
|
||||
**Device Compatibility**
|
||||
- SoundTouch models this applies to: [e.g. all models, SoundTouch 20+, specific models]
|
||||
- Have you tested this manually: [e.g. via curl, Postman, etc.]
|
||||
|
||||
**Examples**
|
||||
Provide examples of how you would like to use this feature:
|
||||
|
||||
```go
|
||||
// Go library example
|
||||
client.NewFeature(parameters)
|
||||
```
|
||||
|
||||
```bash
|
||||
# CLI example
|
||||
soundtouch-cli --host 192.168.1.100 new-feature --param value
|
||||
```
|
||||
|
||||
**Priority**
|
||||
- [ ] Critical - blocks important functionality
|
||||
- [ ] High - would significantly improve user experience
|
||||
- [ ] Medium - nice to have enhancement
|
||||
- [ ] Low - minor improvement
|
||||
|
||||
**Additional context**
|
||||
Add any other context, screenshots, or examples about the feature request here.
|
||||
|
||||
**Related Issues**
|
||||
- Related to #[issue number]
|
||||
- Depends on #[issue number]
|
||||
- Blocks #[issue number]
|
||||
|
||||
---
|
||||
|
||||
**Checklist**
|
||||
- [ ] I have searched existing issues to avoid duplicates
|
||||
- [ ] I have checked the documentation to ensure this feature doesn't already exist
|
||||
- [ ] I have provided a clear use case and rationale
|
||||
- [ ] I have considered the impact on existing functionality
|
||||
- [ ] I understand this may require SoundTouch API support to implement
|
||||
@@ -77,3 +77,24 @@ updates:
|
||||
- "*scan*"
|
||||
- "securecodewarrior/*"
|
||||
- "codecov/*"
|
||||
|
||||
# Docker dependency updates
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "wednesday"
|
||||
time: "09:00"
|
||||
timezone: "UTC"
|
||||
open-pull-requests-limit: 3
|
||||
reviewers:
|
||||
- "gesellix"
|
||||
assignees:
|
||||
- "gesellix"
|
||||
commit-message:
|
||||
prefix: "docker"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
rebase-strategy: "auto"
|
||||
|
||||
@@ -25,6 +25,24 @@
|
||||
},
|
||||
{
|
||||
"pattern": "^https://pkg.go.dev.*badge"
|
||||
},
|
||||
{
|
||||
"pattern": "^\\.\\./images/(dashboard-home|account-creation|account-dashboard|usb-remote-services|device-discovery|device-registration|account-migration|migration-setup|migration-progress|migration-health|migration-complete|backup-setup)\\.png$"
|
||||
},
|
||||
{
|
||||
"pattern": "https://www.contributor-covenant.org/version/2/0/code_of_conduct.html"
|
||||
},
|
||||
{
|
||||
"pattern": "https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/"
|
||||
},
|
||||
{
|
||||
"pattern": "https://apkpure.com/bose-soundtouch/com.bose.soundtouch"
|
||||
},
|
||||
{
|
||||
"pattern": "^https://bose\\.fandom\\.com/"
|
||||
},
|
||||
{
|
||||
"pattern": "^https://www\\.reddit\\.com/"
|
||||
}
|
||||
],
|
||||
"replacementPatterns": [
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
## Description
|
||||
|
||||
Brief description of the changes in this PR.
|
||||
|
||||
## Type of Change
|
||||
|
||||
Please check the type of change your PR introduces:
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Documentation update
|
||||
- [ ] Performance improvement
|
||||
- [ ] Code refactoring (no functional changes)
|
||||
- [ ] Test improvements
|
||||
- [ ] Build/CI improvements
|
||||
|
||||
## Related Issues
|
||||
|
||||
- Fixes #[issue number]
|
||||
- Relates to #[issue number]
|
||||
- Part of #[issue number]
|
||||
|
||||
## Changes Made
|
||||
|
||||
### API Changes
|
||||
- [ ] Added new endpoints
|
||||
- [ ] Modified existing endpoints
|
||||
- [ ] Added new CLI commands
|
||||
- [ ] Modified existing CLI commands
|
||||
- [ ] Added new configuration options
|
||||
|
||||
### Implementation Details
|
||||
- Describe the main changes
|
||||
- List any new dependencies
|
||||
- Mention any architectural changes
|
||||
|
||||
## Testing
|
||||
|
||||
### Automated Tests
|
||||
- [ ] Unit tests added/updated
|
||||
- [ ] Integration tests added/updated
|
||||
- [ ] All existing tests pass
|
||||
- [ ] Test coverage maintained or improved
|
||||
|
||||
### Manual Testing
|
||||
- [ ] Tested with real SoundTouch device(s)
|
||||
- [ ] Tested CLI changes manually
|
||||
- [ ] Tested in different network environments
|
||||
|
||||
**Device(s) tested with:**
|
||||
- Device model: [e.g. SoundTouch 10]
|
||||
- Device IP: [e.g. 192.168.1.100]
|
||||
- Test results: [brief description]
|
||||
|
||||
### Test Commands
|
||||
```bash
|
||||
# Commands used to test this change
|
||||
make test
|
||||
go test ./pkg/client -v -run TestNewFeature
|
||||
soundtouch-cli --host 192.168.1.100 new-command
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] Updated relevant documentation
|
||||
- [ ] Added code comments for complex logic
|
||||
- [ ] Updated CLI help text
|
||||
- [ ] Added usage examples
|
||||
- [ ] Updated API documentation
|
||||
|
||||
**Documentation files updated:**
|
||||
- [ ] README.md
|
||||
- [ ] docs/API-Endpoints-Overview.md
|
||||
- [ ] docs/CLI-REFERENCE.md
|
||||
- [ ] Code documentation (godoc)
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- [ ] This change is backward compatible
|
||||
- [ ] This change includes breaking changes (requires major version bump)
|
||||
- [ ] This change requires configuration migration
|
||||
|
||||
**Breaking changes (if any):**
|
||||
- Describe what breaks
|
||||
- Provide migration instructions
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- [ ] No security implications
|
||||
- [ ] Security review required
|
||||
- [ ] Added input validation
|
||||
- [ ] Updated authentication/authorization
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- [ ] No performance impact
|
||||
- [ ] Performance improvement
|
||||
- [ ] Potential performance regression (justify why)
|
||||
|
||||
**Performance notes:**
|
||||
- Measured impact: [benchmarks, timing, memory usage]
|
||||
- Optimization opportunities: [if any]
|
||||
|
||||
## Code Quality
|
||||
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] No linting errors
|
||||
- [ ] No security warnings
|
||||
- [ ] Memory leaks checked (if applicable)
|
||||
|
||||
### Pre-submission Checklist
|
||||
|
||||
- [ ] `make check` passes (format, lint, vet)
|
||||
- [ ] `make test` passes
|
||||
- [ ] No TODO comments left in production code
|
||||
- [ ] Error handling is comprehensive
|
||||
- [ ] Logging is appropriate (not too verbose, not too quiet)
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
Any special considerations for deployment:
|
||||
- Configuration changes required
|
||||
- Database migrations needed
|
||||
- Service restart required
|
||||
- Rollback procedures
|
||||
|
||||
## Screenshots (if applicable)
|
||||
|
||||
If this PR includes UI changes or CLI output changes, include screenshots or terminal output examples.
|
||||
|
||||
```bash
|
||||
# Before
|
||||
$ soundtouch-cli old-command
|
||||
Old output...
|
||||
|
||||
# After
|
||||
$ soundtouch-cli new-command
|
||||
New improved output...
|
||||
```
|
||||
|
||||
## Additional Notes
|
||||
|
||||
Any additional information that reviewers should know:
|
||||
- Design decisions and trade-offs
|
||||
- Future work planned
|
||||
- Alternative approaches considered
|
||||
- References to external documentation
|
||||
|
||||
## Review Requests
|
||||
|
||||
**Areas that need special attention:**
|
||||
- [ ] Error handling logic
|
||||
- [ ] Performance critical sections
|
||||
- [ ] Security implications
|
||||
- [ ] API design choices
|
||||
- [ ] Documentation clarity
|
||||
|
||||
**Specific questions for reviewers:**
|
||||
1. Question about design choice X?
|
||||
2. Is error handling sufficient in section Y?
|
||||
3. Should we consider alternative approach Z?
|
||||
|
||||
---
|
||||
|
||||
**Reviewer Guidelines:**
|
||||
- Check that all tests pass
|
||||
- Verify documentation is updated
|
||||
- Test manually if device access available
|
||||
- Consider backward compatibility
|
||||
- Evaluate error handling and edge cases
|
||||
+236
-39
@@ -1,5 +1,8 @@
|
||||
name: CI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -14,15 +17,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
@@ -31,6 +34,9 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
@@ -40,8 +46,14 @@ jobs:
|
||||
- name: Run tests
|
||||
run: go test -v -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Build service
|
||||
run: make build-service
|
||||
|
||||
- name: Run HTTP client integration tests
|
||||
run: make test-http-client
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
file: ./coverage.out
|
||||
flags: unittests
|
||||
@@ -54,15 +66,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout=5m
|
||||
@@ -71,39 +86,76 @@ jobs:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm64]
|
||||
exclude:
|
||||
# Windows ARM64 builds are experimental
|
||||
- goos: windows
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: linux
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: freebsd
|
||||
goarch: amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Build CLI
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Build binaries
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
output_name="soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
output_name="${output_name}.exe"
|
||||
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
if [[ -n "${{ matrix.goarm }}" ]]; then
|
||||
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
|
||||
fi
|
||||
go build -o "$output_name" ./cmd/soundtouch-cli
|
||||
|
||||
EXT=""
|
||||
if [[ "${{ matrix.goos }}" == "windows" ]]; then
|
||||
EXT=".exe"
|
||||
fi
|
||||
|
||||
mkdir -p build
|
||||
|
||||
for binary in soundtouch-cli soundtouch-service soundtouch-web soundtouch-backup; do
|
||||
OUTPUT="build/${binary}-${ARCH_SUFFIX}${EXT}"
|
||||
echo "Building $OUTPUT"
|
||||
go build -trimpath -ldflags="-s -w" -o "$OUTPUT" "./cmd/$binary"
|
||||
done
|
||||
|
||||
ls -la build/
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: soundtouch-cli-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: soundtouch-cli-*
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: build/
|
||||
|
||||
security:
|
||||
name: Basic Security Check
|
||||
@@ -111,13 +163,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run basic vulnerability check
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@@ -135,22 +190,43 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check documentation links
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
use-quiet-mode: "yes"
|
||||
use-verbose-mode: "yes"
|
||||
config-file: ".github/markdown-link-check.json"
|
||||
run: |
|
||||
npm install -g markdown-link-check
|
||||
find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json
|
||||
|
||||
- name: Warn on pending images
|
||||
run: |
|
||||
IMAGES=(
|
||||
"dashboard-home.png"
|
||||
"account-creation.png"
|
||||
"account-dashboard.png"
|
||||
"usb-remote-services.png"
|
||||
"device-discovery.png"
|
||||
"device-registration.png"
|
||||
"account-migration.png"
|
||||
"migration-setup.png"
|
||||
"migration-progress.png"
|
||||
"migration-health.png"
|
||||
"migration-complete.png"
|
||||
"backup-setup.png"
|
||||
)
|
||||
|
||||
for img in "${IMAGES[@]}"; do
|
||||
if [ ! -f "docs/images/$img" ]; then
|
||||
echo "::warning file=docs/guides/MIGRATION-GUIDE.md::Pending image '$img' is missing from docs/images/"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Validate API documentation
|
||||
run: |
|
||||
# Check that all documented endpoints exist in code
|
||||
echo "Validating API documentation consistency..."
|
||||
|
||||
# Extract endpoint patterns from cookbook
|
||||
if [ -f "docs/API-COOKBOOK.md" ]; then
|
||||
# Check API cookbook
|
||||
if [ -f "docs/reference/API-COOKBOOK.md" ]; then
|
||||
echo "✓ API Cookbook exists"
|
||||
else
|
||||
echo "✗ API Cookbook missing"
|
||||
@@ -158,7 +234,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# Check getting started guide
|
||||
if [ -f "docs/GETTING-STARTED.md" ]; then
|
||||
if [ -f "docs/guides/GETTING-STARTED.md" ]; then
|
||||
echo "✓ Getting Started guide exists"
|
||||
else
|
||||
echo "✗ Getting Started guide missing"
|
||||
@@ -172,16 +248,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Test CLI build and help
|
||||
run: |
|
||||
go build -o soundtouch-cli ./cmd/soundtouch-cli
|
||||
go build -trimpath -ldflags="-s -w" -o soundtouch-cli ./cmd/soundtouch-cli
|
||||
./soundtouch-cli -help
|
||||
|
||||
- name: Test library imports
|
||||
@@ -218,10 +294,129 @@ jobs:
|
||||
go run test_import.go
|
||||
rm test_import.go
|
||||
|
||||
docker:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Determine push eligibility
|
||||
id: push-check
|
||||
run: |
|
||||
# Push on main, and on same-repo PRs (forks can't push to GHCR via GITHUB_TOKEN).
|
||||
SHOULD_PUSH="false"
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
SHOULD_PUSH="true"
|
||||
elif [[ "${{ github.event_name }}" == "pull_request" && \
|
||||
"${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
|
||||
SHOULD_PUSH="true"
|
||||
fi
|
||||
echo "should-push=$SHOULD_PUSH" >> "$GITHUB_OUTPUT"
|
||||
echo "Will push: $SHOULD_PUSH"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr,prefix=preview-pr-
|
||||
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=ref,event=pr,prefix=preview-pr-
|
||||
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
|
||||
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: ${{ steps.push-check.outputs.should-push == 'true' }}
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Summarize published images
|
||||
if: steps.push-check.outputs.should-push == 'true'
|
||||
env:
|
||||
SERVICE_TAGS: ${{ steps.meta-service.outputs.tags }}
|
||||
WEB_TAGS: ${{ steps.meta-web.outputs.tags }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
{
|
||||
echo "## 🐳 Published Docker Images"
|
||||
echo ""
|
||||
if [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
echo "**Preview** images for PR #${PR_NUMBER}. These are not release builds."
|
||||
elif [[ "$REF_NAME" == "main" ]]; then
|
||||
echo "**Edge** images from \`main\`."
|
||||
else
|
||||
echo "**Preview** images from branch \`${REF_NAME}\`. These are not release builds."
|
||||
fi
|
||||
echo ""
|
||||
echo "### soundtouch-service"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && echo "docker pull $tag"
|
||||
done <<< "$SERVICE_TAGS"
|
||||
echo '```'
|
||||
echo ""
|
||||
echo "### soundtouch-web"
|
||||
echo ""
|
||||
echo '```bash'
|
||||
while IFS= read -r tag; do
|
||||
[[ -n "$tag" ]] && echo "docker pull $tag"
|
||||
done <<< "$WEB_TAGS"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
notify:
|
||||
name: Notify Status
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, lint, build, security, docs]
|
||||
needs: [test, lint, build, security, docs, docker]
|
||||
if: always()
|
||||
permissions:
|
||||
statuses: write
|
||||
@@ -234,7 +429,8 @@ jobs:
|
||||
"${{ needs.lint.result }}" == "success" && \
|
||||
"${{ needs.build.result }}" == "success" && \
|
||||
"${{ needs.security.result }}" == "success" && \
|
||||
"${{ needs.docs.result }}" == "success" ]]; then
|
||||
"${{ needs.docs.result }}" == "success" && \
|
||||
"${{ needs.docker.result }}" == "success" ]]; then
|
||||
echo "✅ All CI checks passed!"
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -244,13 +440,14 @@ jobs:
|
||||
echo "Build: ${{ needs.build.result }}"
|
||||
echo "Security: ${{ needs.security.result }}"
|
||||
echo "Docs: ${{ needs.docs.result }}"
|
||||
echo "Docker: ${{ needs.docker.result }}"
|
||||
echo "status=failure" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
id: status
|
||||
|
||||
- name: Update commit status
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Deploy Documentation
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
- name: Build with Jekyll
|
||||
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
|
||||
with:
|
||||
source: 'docs/'
|
||||
destination: '_site'
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: '_site'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
+211
-69
@@ -10,6 +10,11 @@ on:
|
||||
required: true
|
||||
default: "v1.0.0"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
GO_VERSION_FILE: "go.mod"
|
||||
|
||||
@@ -23,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -59,10 +64,13 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Run tests before release
|
||||
run: |
|
||||
echo "Running final tests before release..."
|
||||
@@ -94,75 +102,118 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: ${{ env.GO_VERSION_FILE }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Build binary
|
||||
- name: Build binaries
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
# Determine output filename
|
||||
BINARY_NAME="soundtouch-cli"
|
||||
# Common variables
|
||||
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
|
||||
|
||||
if [[ "${{ matrix.goarm }}" != "" ]]; then
|
||||
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
|
||||
fi
|
||||
|
||||
if [[ "${{ matrix.goos }}" == "windows" ]]; then
|
||||
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
|
||||
else
|
||||
OUTPUT_NAME="${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
|
||||
fi
|
||||
# Function to build a binary
|
||||
build_binary() {
|
||||
local BINARY_NAME=$1
|
||||
local CMD_PATH=$2
|
||||
local OUTPUT_NAME
|
||||
|
||||
echo "Building: $OUTPUT_NAME"
|
||||
# Ensure build directory exists
|
||||
mkdir -p build
|
||||
|
||||
# Build with optimizations and version info
|
||||
go build \
|
||||
-ldflags="-s -w -X main.version=v${{ needs.validate.outputs.version }} -X main.commit=${{ github.sha }} -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
-o "$OUTPUT_NAME" \
|
||||
./cmd/soundtouch-cli
|
||||
if [[ "${{ matrix.goos }}" == "windows" ]]; then
|
||||
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}.exe"
|
||||
else
|
||||
OUTPUT_NAME="build/${BINARY_NAME}-v${{ needs.validate.outputs.version }}-${ARCH_SUFFIX}"
|
||||
fi
|
||||
|
||||
# Verify binary was created and is executable
|
||||
ls -la "$OUTPUT_NAME"
|
||||
file "$OUTPUT_NAME"
|
||||
echo "Building $BINARY_NAME: $OUTPUT_NAME"
|
||||
|
||||
echo "binary_name=$OUTPUT_NAME" >> $GITHUB_OUTPUT
|
||||
# Ensure clean build environment for this binary
|
||||
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
|
||||
|
||||
if ! go build \
|
||||
-trimpath \
|
||||
-ldflags="-s -w" \
|
||||
-o "$OUTPUT_NAME" \
|
||||
"$CMD_PATH"; then
|
||||
echo "❌ Build failed for $BINARY_NAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify binary was created
|
||||
ls -la "$OUTPUT_NAME"
|
||||
echo "$BINARY_NAME=$OUTPUT_NAME" >> $GITHUB_OUTPUT
|
||||
}
|
||||
|
||||
# Build CLI
|
||||
build_binary "soundtouch-cli" "./cmd/soundtouch-cli"
|
||||
|
||||
# Build Service
|
||||
build_binary "soundtouch-service" "./cmd/soundtouch-service"
|
||||
|
||||
# Build Web
|
||||
build_binary "soundtouch-web" "./cmd/soundtouch-web"
|
||||
|
||||
# Build Backup
|
||||
build_binary "soundtouch-backup" "./cmd/soundtouch-backup"
|
||||
id: build
|
||||
|
||||
- name: Generate individual checksum
|
||||
- name: Generate individual checksums
|
||||
run: |
|
||||
OUTPUT_NAME="${{ steps.build.outputs.binary_name }}"
|
||||
sha256sum "$OUTPUT_NAME" > "$OUTPUT_NAME.sha256"
|
||||
sha512sum "$OUTPUT_NAME" > "$OUTPUT_NAME.sha512"
|
||||
CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}"
|
||||
SVC_NAME="${{ steps.build.outputs.soundtouch-service }}"
|
||||
WEB_NAME="${{ steps.build.outputs.soundtouch-web }}"
|
||||
BCK_NAME="${{ steps.build.outputs.soundtouch-backup }}"
|
||||
|
||||
echo "📋 Generated individual checksums:"
|
||||
cat "$OUTPUT_NAME.sha256"
|
||||
cat "$OUTPUT_NAME.sha512"
|
||||
# Use atomic operations to avoid conflicts
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
|
||||
generate_checksums() {
|
||||
local FILE=$1
|
||||
echo "Building checksums for: $FILE"
|
||||
sha256sum "$FILE" > "${TEMP_DIR}/$(basename "$FILE").sha256"
|
||||
sha512sum "$FILE" > "${TEMP_DIR}/$(basename "$FILE").sha512"
|
||||
mv "${TEMP_DIR}/$(basename "$FILE").sha256" "$FILE.sha256"
|
||||
mv "${TEMP_DIR}/$(basename "$FILE").sha512" "$FILE.sha512"
|
||||
}
|
||||
|
||||
generate_checksums "$CLI_NAME"
|
||||
generate_checksums "$SVC_NAME"
|
||||
generate_checksums "$WEB_NAME"
|
||||
generate_checksums "$BCK_NAME"
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$TEMP_DIR"
|
||||
echo "✅ Checksums generated successfully"
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ steps.build.outputs.binary_name }}
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
|
||||
path: |
|
||||
${{ steps.build.outputs.binary_name }}
|
||||
${{ steps.build.outputs.binary_name }}.sha256
|
||||
${{ steps.build.outputs.binary_name }}.sha512
|
||||
build/soundtouch-cli-v*
|
||||
build/soundtouch-service-v*
|
||||
build/soundtouch-web-v*
|
||||
build/soundtouch-backup-v*
|
||||
retention-days: 1
|
||||
|
||||
checksums:
|
||||
@@ -171,9 +222,10 @@ jobs:
|
||||
needs: [validate, build]
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
- name: Download binary artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: binaries-*
|
||||
path: ./binaries
|
||||
|
||||
- name: Generate checksums
|
||||
@@ -182,31 +234,36 @@ jobs:
|
||||
|
||||
# Debug: Show the downloaded structure
|
||||
echo "📁 Downloaded artifact structure:"
|
||||
find . -type f -name "soundtouch-cli-*"
|
||||
ls -R
|
||||
|
||||
# Flatten directory structure (artifacts are in subdirs)
|
||||
# Move all binary files to current directory
|
||||
find . -type f -name "soundtouch-cli-*" -exec mv {} . \;
|
||||
# Create a collection directory to avoid naming conflicts
|
||||
mkdir -p release-files
|
||||
|
||||
# Move all files from subdirectories to the collection directory
|
||||
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
|
||||
|
||||
# Remove empty directories
|
||||
find . -type d -empty -delete
|
||||
|
||||
# Move to the collection directory for the rest of the processing
|
||||
cd release-files
|
||||
|
||||
# Debug: Show flattened structure
|
||||
echo "📁 Flattened structure:"
|
||||
ls -la soundtouch-cli-* || echo "No files found matching pattern"
|
||||
ls -la soundtouch-* || echo "No files found matching pattern"
|
||||
|
||||
# Generate combined checksums (exclude individual .sha256/.sha512 files)
|
||||
if ls soundtouch-cli-v* 1> /dev/null 2>&1; then
|
||||
if ls soundtouch-* 1> /dev/null 2>&1; then
|
||||
# Only checksum the actual binaries, not the .sha256/.sha512 files
|
||||
ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
|
||||
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
|
||||
|
||||
echo "📋 Generated combined checksums:"
|
||||
cat checksums.sha256
|
||||
|
||||
# Verify all expected files are present (binaries only, not checksum files)
|
||||
EXPECTED_COUNT=7 # Based on build matrix
|
||||
ACTUAL_COUNT=$(ls soundtouch-cli-v* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
|
||||
EXPECTED_COUNT=28 # 7 platforms * 4 binaries
|
||||
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
|
||||
|
||||
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
|
||||
echo "❌ Expected $EXPECTED_COUNT binaries, found $ACTUAL_COUNT"
|
||||
@@ -223,21 +280,21 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload checksums
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: checksums
|
||||
path: |
|
||||
binaries/checksums.sha256
|
||||
binaries/checksums.sha512
|
||||
binaries/*.sha256
|
||||
binaries/*.sha512
|
||||
binaries/release-files/checksums.sha256
|
||||
binaries/release-files/checksums.sha512
|
||||
binaries/release-files/*.sha256
|
||||
binaries/release-files/*.sha512
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload all release assets
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: binaries/
|
||||
path: binaries/release-files/
|
||||
retention-days: 1
|
||||
|
||||
create_release:
|
||||
@@ -248,12 +305,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
@@ -320,19 +377,32 @@ jobs:
|
||||
- [Troubleshooting Guide](docs/TROUBLESHOOTING.md) - Systematic issue resolution
|
||||
- [Deployment Guide](docs/DEPLOYMENT.md) - Production deployment examples (Docker, K8s, systemd)
|
||||
|
||||
## 🔧 CLI Tool
|
||||
## 🔧 CLI & Service Tools
|
||||
|
||||
Download the CLI tool for your platform from the assets below:
|
||||
Download the tools for your platform from the assets below:
|
||||
|
||||
### CLI Tool
|
||||
\`\`\`bash
|
||||
# Quick device discovery
|
||||
./soundtouch-cli -discover
|
||||
\`\`\`
|
||||
|
||||
# Get device information
|
||||
./soundtouch-cli -host 192.168.1.100 -info
|
||||
### SoundTouch Service
|
||||
\`\`\`bash
|
||||
# Start the service
|
||||
./soundtouch-service
|
||||
\`\`\`
|
||||
|
||||
# Monitor real-time events
|
||||
./soundtouch-cli -host 192.168.1.100 -nowplaying
|
||||
### SoundTouch Web
|
||||
\`\`\`bash
|
||||
# Start the web app
|
||||
./soundtouch-web
|
||||
\`\`\`
|
||||
|
||||
### SoundTouch Backup
|
||||
\`\`\`bash
|
||||
# Back up cloud account and all paired speakers in one go
|
||||
./soundtouch-backup all
|
||||
\`\`\`
|
||||
|
||||
## 🧪 Tested Hardware
|
||||
@@ -353,6 +423,8 @@ jobs:
|
||||
- Windows (amd64)
|
||||
- FreeBSD (amd64)
|
||||
|
||||
`soundtouch-cli`, `soundtouch-service`, `soundtouch-web`, and `soundtouch-backup` are included.
|
||||
|
||||
## 🔐 Checksums
|
||||
|
||||
Multiple checksum options are provided for download verification:
|
||||
@@ -396,7 +468,7 @@ jobs:
|
||||
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ github.event.inputs.tag }}
|
||||
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
|
||||
@@ -405,6 +477,9 @@ jobs:
|
||||
prerelease: ${{ needs.validate.outputs.is_prerelease == 'true' }}
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
@@ -419,34 +494,101 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download release assets
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-assets
|
||||
path: ./release-assets
|
||||
|
||||
- name: Upload additional assets to existing release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
|
||||
with:
|
||||
tag_name: ${{ github.event.release.tag_name }}
|
||||
files: |
|
||||
release-assets/soundtouch-cli-v*
|
||||
release-assets/soundtouch-service-v*
|
||||
release-assets/soundtouch-web-v*
|
||||
release-assets/soundtouch-backup-v*
|
||||
release-assets/checksums.sha256
|
||||
release-assets/checksums.sha512
|
||||
fail_on_unmatched_files: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
docker:
|
||||
name: Build and Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: validate
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-service
|
||||
id: meta-service
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-service Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta-service.outputs.tags }}
|
||||
labels: ${{ steps.meta-service.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Extract metadata (tags, labels) for soundtouch-web
|
||||
id: meta-web
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-web
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push soundtouch-web Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
target: soundtouch-web
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
push: true
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
notify:
|
||||
name: Post-Release Notifications
|
||||
runs-on: ubuntu-latest
|
||||
needs: [validate, create_release, update_release]
|
||||
if: always() && (needs.create_release.result == 'success' || needs.update_release.result == 'success')
|
||||
needs: [validate, create_release, update_release, docker]
|
||||
if: always() && (needs.create_release.result == 'success' || needs.update_release.result == 'success' || needs.docker.result == 'success')
|
||||
|
||||
steps:
|
||||
- name: Notify success
|
||||
run: |
|
||||
echo "🎉 Release ${{ needs.validate.outputs.version }} completed successfully!"
|
||||
echo "📦 Binaries built for 7 platforms"
|
||||
echo "📦 Binaries built for 7 platforms (CLI, Service, Web, and Backup)"
|
||||
echo "🐳 Docker image published to ghcr.io"
|
||||
echo "🔐 Checksums generated and verified"
|
||||
echo "📋 Release notes automatically generated"
|
||||
echo ""
|
||||
|
||||
@@ -14,16 +14,21 @@ jobs:
|
||||
vulnerability-scan:
|
||||
name: Vulnerability Scan
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Install security scanning tools
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@@ -43,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Upload vulnerability scan results
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vulnerability-scan-results
|
||||
path: |
|
||||
@@ -53,16 +58,21 @@ jobs:
|
||||
static-analysis:
|
||||
name: Static Security Analysis
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Install static analysis tools
|
||||
run: |
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
@@ -74,7 +84,7 @@ jobs:
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Run Semgrep security analysis
|
||||
uses: semgrep/semgrep-action@v1
|
||||
uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 (no v1.x.y semver tag exists)
|
||||
with:
|
||||
config: >-
|
||||
p/security-audit
|
||||
@@ -85,7 +95,7 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
@@ -100,33 +110,38 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install libpcap
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
languages: go
|
||||
config-file: ./.github/codeql-config.yml
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
category: "/language:go"
|
||||
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@v4
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
fail-on-severity: moderate
|
||||
allow-ghsas: GHSA-xxxx-xxxx-xxxx # Add specific allowlisted advisories if needed
|
||||
@@ -137,6 +152,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [vulnerability-scan, static-analysis, codeql-analysis]
|
||||
if: always()
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Security scan summary
|
||||
|
||||
+26
@@ -11,13 +11,29 @@ dist/
|
||||
#example-mdns
|
||||
#example-upnp
|
||||
|
||||
# Root-level binary executables (exclude built binaries in root)
|
||||
/soundtouch-backup
|
||||
/soundtouch-cli
|
||||
/soundtouch-service
|
||||
/soundtouch-web
|
||||
/dummy-speaker
|
||||
/example-mdns
|
||||
/example-upnp
|
||||
/example-unified
|
||||
/mdns-scanner
|
||||
/websocket-demo
|
||||
/main
|
||||
/screenshots
|
||||
|
||||
# Environment configuration
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
docker-compose.override.yml
|
||||
|
||||
# Test coverage reports
|
||||
coverage.out
|
||||
coverage*.out
|
||||
coverage.html
|
||||
*.prof
|
||||
|
||||
@@ -44,6 +60,15 @@ vendor/
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Android MITM setup — downloaded/generated artefacts, not committed
|
||||
scripts/android/bose.apk
|
||||
scripts/android/frida-server
|
||||
scripts/android/frida-server.xz
|
||||
scripts/android/frida/
|
||||
scripts/android/frida-venv/
|
||||
scripts/android/captures/
|
||||
scripts/android/mitm/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
@@ -51,6 +76,7 @@ Thumbs.db
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
.output.txt
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
|
||||
+11
-1
@@ -50,7 +50,12 @@ linters:
|
||||
linters:
|
||||
- gocritic # Can be overly strict for test code
|
||||
- wsl # Whitespace less critical in tests
|
||||
- wsl_v5 # Whitespace less critical in tests
|
||||
- gocyclo # Complexity less critical in tests
|
||||
- govet # Avoid shadow warnings in tests
|
||||
- revive # Avoid exported/package-comments in tests
|
||||
- errcheck # Avoid mandatory error checks in tests
|
||||
- unparam # Often parameters are fixed in test setups
|
||||
|
||||
# Exclude specific rules for generated files
|
||||
- path: ".*\\.pb\\.go$"
|
||||
@@ -62,6 +67,11 @@ linters:
|
||||
- staticcheck
|
||||
text: "SA9003:" # Empty branch
|
||||
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: "SA1008: keys in http.Header are canonicalized"
|
||||
path: pkg/service/handlers/handlers_etag_test.go
|
||||
|
||||
# Allow main functions to not check errors in examples
|
||||
- path: cmd/.*\.go
|
||||
text: "Error return value of.*is not checked"
|
||||
@@ -85,7 +95,7 @@ linters:
|
||||
- fieldalignment # Can be overly aggressive
|
||||
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
min-complexity: 20
|
||||
|
||||
gocritic:
|
||||
enabled-checks:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
tobias@gesellix.de.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
+478
@@ -0,0 +1,478 @@
|
||||
# Contributing to Bose SoundTouch API Client
|
||||
|
||||
Thank you for your interest in contributing to the Bose SoundTouch API Client! This project aims to provide a comprehensive, reliable, and well-tested Go library for controlling Bose SoundTouch devices.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [How Can I Contribute?](#how-can-i-contribute)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Coding Guidelines](#coding-guidelines)
|
||||
- [Testing Guidelines](#testing-guidelines)
|
||||
- [Documentation Guidelines](#documentation-guidelines)
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Device Testing](#device-testing)
|
||||
- [Community](#community)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project adheres to our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to the project maintainers.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Go 1.25.6 or later**: [Download Go](https://golang.org/dl/)
|
||||
- **Git**: For version control
|
||||
- **Make**: For build automation (optional but recommended)
|
||||
- **SoundTouch Device**: For testing (optional but valuable)
|
||||
|
||||
### First Contribution
|
||||
|
||||
1. **Fork the repository** on GitHub
|
||||
2. **Clone your fork** locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR-USERNAME/Bose-SoundTouch.git
|
||||
cd Bose-SoundTouch
|
||||
```
|
||||
3. **Install dependencies**:
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
4. **Run tests** to ensure everything works:
|
||||
```bash
|
||||
make test
|
||||
# or
|
||||
go test ./...
|
||||
```
|
||||
5. **Build the CLI** to test functionality:
|
||||
```bash
|
||||
make build
|
||||
./soundtouch-cli --help
|
||||
```
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### 🐛 Reporting Bugs
|
||||
|
||||
Before creating a bug report, please:
|
||||
|
||||
1. **Check existing issues** to avoid duplicates
|
||||
2. **Test with the latest version** from the main branch
|
||||
3. **Include device information** (model, firmware version if known)
|
||||
|
||||
When filing a bug report, include:
|
||||
|
||||
- **Clear title** describing the issue
|
||||
- **Steps to reproduce** the behavior
|
||||
- **Expected behavior** vs actual behavior
|
||||
- **Environment details**: OS, Go version, device model
|
||||
- **Log output** if applicable (use `--verbose` flag)
|
||||
|
||||
### 💡 Suggesting Features
|
||||
|
||||
Feature requests are welcome! Please:
|
||||
|
||||
1. **Check if the feature already exists** in documentation
|
||||
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/reference/API-ENDPOINTS.md))
|
||||
3. **Explain the use case** and how it benefits users
|
||||
|
||||
### 🔧 Contributing Code
|
||||
|
||||
Areas where contributions are especially welcome:
|
||||
|
||||
#### High Priority
|
||||
- **Bug fixes** for existing functionality
|
||||
- **Device compatibility** improvements
|
||||
- **Error handling** enhancements
|
||||
- **Performance optimizations**
|
||||
|
||||
#### Medium Priority
|
||||
- **New endpoint implementations** (if officially documented)
|
||||
- **CLI improvements** (better UX, additional commands)
|
||||
- **Documentation improvements**
|
||||
- **Example applications**
|
||||
|
||||
#### Future Enhancements
|
||||
- **Web interface** development
|
||||
- **Home Assistant integration**
|
||||
- **WASM/browser support**
|
||||
- **Mobile app development**
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
Bose-SoundTouch/
|
||||
├── cmd/ # Command-line applications
|
||||
│ ├── soundtouch-cli/ # Main CLI tool
|
||||
│ └── examples/ # Example applications
|
||||
├── pkg/ # Library packages
|
||||
│ ├── client/ # HTTP client implementation
|
||||
│ ├── discovery/ # Device discovery
|
||||
│ ├── models/ # Data structures
|
||||
│ └── config/ # Configuration management
|
||||
├── docs/ # Documentation
|
||||
├── examples/ # Usage examples
|
||||
└── scripts/ # Build and utility scripts
|
||||
```
|
||||
|
||||
### Development Commands
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Run tests with coverage
|
||||
make test-coverage
|
||||
|
||||
# Build all binaries
|
||||
make build
|
||||
|
||||
# Run linting and formatting
|
||||
make check
|
||||
|
||||
# Run golangci-lint specifically
|
||||
golangci-lint run
|
||||
|
||||
# Auto-fix linting issues where possible
|
||||
golangci-lint run --fix
|
||||
|
||||
# Install CLI locally
|
||||
go install ./cmd/soundtouch-cli
|
||||
|
||||
# Run integration tests (requires real device)
|
||||
make test-integration HOST=192.168.1.100
|
||||
```
|
||||
|
||||
### Environment Setup
|
||||
|
||||
For development with real devices, create a `.env` file:
|
||||
|
||||
```env
|
||||
# Optional: Pre-configured device for testing
|
||||
SOUNDTOUCH_HOST=192.168.1.100
|
||||
SOUNDTOUCH_PORT=8090
|
||||
|
||||
# Optional: Enable debug logging
|
||||
SOUNDTOUCH_DEBUG=true
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
### Before Submitting
|
||||
|
||||
1. **Create an issue** first for significant changes
|
||||
2. **Fork and create a feature branch**:
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
3. **Write tests** for your changes
|
||||
4. **Update documentation** if needed
|
||||
5. **Run the full test suite**:
|
||||
```bash
|
||||
make check
|
||||
make test
|
||||
```
|
||||
|
||||
### Pull Request Guidelines
|
||||
|
||||
1. **Clear title** describing the change
|
||||
2. **Detailed description** explaining:
|
||||
- What the change does
|
||||
- Why it's needed
|
||||
- How it was tested
|
||||
- Any breaking changes
|
||||
3. **Link to related issues**
|
||||
4. **Update CHANGELOG.md** if applicable
|
||||
5. **Ensure CI passes**
|
||||
|
||||
### Review Process
|
||||
|
||||
- At least one maintainer will review your PR
|
||||
- Feedback will be constructive and specific
|
||||
- Address feedback in additional commits
|
||||
- Once approved, a maintainer will merge your PR
|
||||
|
||||
## Coding Guidelines
|
||||
|
||||
### Go Style
|
||||
|
||||
Follow standard Go conventions:
|
||||
|
||||
- **gofmt** for formatting
|
||||
- **golangci-lint** for comprehensive code quality checks
|
||||
- **go vet** for static analysis
|
||||
- **Effective Go** principles
|
||||
- **Standard library patterns** where applicable
|
||||
|
||||
### Code Organization
|
||||
|
||||
```go
|
||||
// Package-level documentation
|
||||
package client
|
||||
|
||||
import (
|
||||
// Standard library first
|
||||
"context"
|
||||
"encoding/xml"
|
||||
|
||||
// Third-party packages
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
// Local packages
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// Public API should be well-documented
|
||||
// GetDeviceInfo retrieves comprehensive device information including
|
||||
// model, capabilities, network status, and current configuration.
|
||||
func (c *Client) GetDeviceInfo() (*models.DeviceInfo, error) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **Return errors** instead of panicking
|
||||
- **Wrap errors** with context using `fmt.Errorf`
|
||||
- **Create custom error types** for specific conditions
|
||||
- **Validate inputs** and return helpful error messages
|
||||
|
||||
```go
|
||||
// Good error handling example
|
||||
func (c *Client) SetVolume(level int) error {
|
||||
if level < 0 || level > 100 {
|
||||
return fmt.Errorf("volume level %d out of range [0-100]", level)
|
||||
}
|
||||
|
||||
if err := c.post("/volume", volumeXML); err != nil {
|
||||
return fmt.Errorf("failed to set volume to %d: %w", level, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### API Design
|
||||
|
||||
- **Consistent method naming**: `Get*`, `Set*`, `Send*`, etc.
|
||||
- **Return pointers** for complex types, values for simple types
|
||||
- **Accept contexts** for potentially long-running operations
|
||||
- **Provide convenience methods** for common operations
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Test Structure
|
||||
|
||||
```go
|
||||
func TestClient_SetVolume(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
volume int
|
||||
expectedError string
|
||||
setupMock func(*httptest.Server)
|
||||
}{
|
||||
{
|
||||
name: "valid volume level",
|
||||
volume: 50,
|
||||
setupMock: func(server *httptest.Server) {
|
||||
// Mock setup
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "volume too high",
|
||||
volume: 150,
|
||||
expectedError: "volume level 150 out of range",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test implementation
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Categories
|
||||
|
||||
1. **Unit Tests**: Test individual functions with mocks
|
||||
2. **Integration Tests**: Test with real devices (when available)
|
||||
3. **Benchmark Tests**: Performance testing for critical paths
|
||||
|
||||
### Mock Usage
|
||||
|
||||
Use `httptest.Server` for HTTP client testing:
|
||||
|
||||
```go
|
||||
func setupMockServer() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/info":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, mockDeviceInfoXML)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
### Real Device Testing
|
||||
|
||||
When possible, test with real SoundTouch devices:
|
||||
|
||||
```bash
|
||||
# Set device IP for integration tests
|
||||
export SOUNDTOUCH_HOST=192.168.1.100
|
||||
go test -tags integration ./pkg/client/
|
||||
```
|
||||
|
||||
## Documentation Guidelines
|
||||
|
||||
### Code Documentation
|
||||
|
||||
- **Package documentation** for every package
|
||||
- **Function documentation** for all public functions
|
||||
- **Example documentation** for complex usage
|
||||
|
||||
```go
|
||||
// Package client provides a comprehensive HTTP client for the Bose SoundTouch Web API.
|
||||
//
|
||||
// The client supports all documented SoundTouch endpoints including device information,
|
||||
// playback control, volume management, and real-time WebSocket events.
|
||||
//
|
||||
// Basic usage:
|
||||
//
|
||||
// client := client.NewClient(&client.Config{
|
||||
// Host: "192.168.1.100",
|
||||
// Port: 8090,
|
||||
// })
|
||||
//
|
||||
// info, err := client.GetDeviceInfo()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// fmt.Printf("Device: %s\n", info.Name)
|
||||
package client
|
||||
```
|
||||
|
||||
### User Documentation
|
||||
|
||||
- **README.md**: Overview and quick start
|
||||
- **API documentation**: Comprehensive endpoint reference
|
||||
- **Examples**: Real-world usage patterns
|
||||
- **Troubleshooting**: Common issues and solutions
|
||||
|
||||
### Documentation Updates
|
||||
|
||||
When making changes:
|
||||
|
||||
1. **Update relevant docs** in the same PR
|
||||
2. **Include usage examples** for new features
|
||||
3. **Update CLI help text** if applicable
|
||||
4. **Test documentation** (ensure examples work)
|
||||
|
||||
## Device Testing
|
||||
|
||||
### Supported Devices
|
||||
|
||||
The library has been tested with:
|
||||
|
||||
- **SoundTouch 10** (firmware unknown)
|
||||
- **SoundTouch 20** (firmware unknown)
|
||||
|
||||
### Testing New Devices
|
||||
|
||||
If you have access to other SoundTouch models:
|
||||
|
||||
1. **Run discovery** to find devices:
|
||||
```bash
|
||||
./soundtouch-cli discover devices
|
||||
```
|
||||
|
||||
2. **Test basic functionality**:
|
||||
```bash
|
||||
./soundtouch-cli -h 192.168.1.100 info get
|
||||
./soundtouch-cli -h 192.168.1.100 now-playing get
|
||||
```
|
||||
|
||||
3. **Report compatibility** in your PR or issue
|
||||
4. **Include device information** from the info endpoint
|
||||
|
||||
### Testing Protocol
|
||||
|
||||
For significant changes:
|
||||
|
||||
1. **Test on multiple devices** if available
|
||||
2. **Test error scenarios** (device offline, network issues)
|
||||
3. **Test edge cases** (invalid inputs, boundary conditions)
|
||||
4. **Document any device-specific behavior**
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
### Security Issues
|
||||
|
||||
**Do not open public issues for security vulnerabilities.** Instead:
|
||||
|
||||
1. **Email the maintainers** with details
|
||||
2. **Allow reasonable time** for response
|
||||
3. **Coordinate disclosure** timing
|
||||
|
||||
### Bug Reports
|
||||
|
||||
Use the bug report template and include:
|
||||
|
||||
- **Device model and firmware** (if known)
|
||||
- **Complete error messages and logs**
|
||||
- **Minimal reproduction case**
|
||||
- **Environment information**
|
||||
|
||||
### Feature Requests
|
||||
|
||||
Use the feature request template and include:
|
||||
|
||||
- **Clear description** of the desired functionality
|
||||
- **Use case explanation**
|
||||
- **API documentation reference** (if applicable)
|
||||
- **Alternative solutions** you've considered
|
||||
|
||||
## Community
|
||||
|
||||
### Communication Channels
|
||||
|
||||
- **GitHub Issues**: Bug reports, feature requests
|
||||
- **GitHub Discussions**: Questions, ideas, general discussion
|
||||
- **Pull Requests**: Code contributions and reviews
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. **Check existing documentation** first
|
||||
2. **Search closed issues** for similar problems
|
||||
3. **Create a new issue** with detailed information
|
||||
4. **Be patient and respectful** in all interactions
|
||||
|
||||
### Recognition
|
||||
|
||||
Contributors will be:
|
||||
|
||||
- **Listed in CONTRIBUTORS.md**
|
||||
- **Mentioned in release notes** for significant contributions
|
||||
- **Credited in documentation** where appropriate
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Go Documentation](https://golang.org/doc/)
|
||||
- [Effective Go](https://golang.org/doc/effective_go.html)
|
||||
- [Bose SoundTouch API Documentation](docs/reference/API-ENDPOINTS.md)
|
||||
- [Project Architecture](docs/PROJECT-PATTERNS.md)
|
||||
- [Development Status](docs/archive/STATUS.md)
|
||||
|
||||
---
|
||||
|
||||
**Thank you for contributing!** Every contribution helps make this library better for the entire SoundTouch community.
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
|
||||
|
||||
# Declare automatic platform ARGs to make them available in build stage
|
||||
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
|
||||
# We should not set defaults here, but rely on BuildKit to set them matching the BUILDPLATFORM
|
||||
ARG TARGETARCH
|
||||
ARG TARGETOS
|
||||
ARG TARGETVARIANT
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod and sum files
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy the rest of the source code
|
||||
COPY . .
|
||||
|
||||
# Build the soundtouch-service
|
||||
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
else \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-service ./cmd/soundtouch-service; \
|
||||
fi
|
||||
|
||||
# Build the soundtouch-web
|
||||
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
else \
|
||||
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-web ./cmd/soundtouch-web; \
|
||||
fi
|
||||
|
||||
# soundtouch-service image
|
||||
FROM alpine:3.23 AS soundtouch-service
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /soundtouch-service /app/soundtouch-service
|
||||
|
||||
# Verify the binary works on the target platform
|
||||
RUN /app/soundtouch-service version || echo "Binary verification complete"
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
ENV PORT=8000
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV LOG_PROXY_BODY=false
|
||||
ENV REDACT_PROXY_LOGS=true
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-service"]
|
||||
|
||||
# soundtouch-web image
|
||||
FROM alpine:3.23 AS soundtouch-web
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /soundtouch-web /app/soundtouch-web
|
||||
|
||||
ENV PORT=8080
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/app/soundtouch-web"]
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help
|
||||
.PHONY: all build build-cli test test-coverage check fmt vet lint clean dev help screenshots
|
||||
|
||||
# Go parameters
|
||||
GOCMD=go
|
||||
@@ -12,73 +12,111 @@ GOFMT=gofmt
|
||||
# Build parameters
|
||||
BINARY_NAME=soundtouch-cli
|
||||
BINARY_PATH=./cmd/$(BINARY_NAME)
|
||||
SERVICE_NAME=soundtouch-service
|
||||
SERVICE_PATH=./cmd/$(SERVICE_NAME)
|
||||
WEB_NAME=soundtouch-web
|
||||
WEB_PATH=./cmd/$(WEB_NAME)
|
||||
EXAMPLE_MDNS_NAME=example-mdns
|
||||
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
|
||||
EXAMPLE_UPNP_NAME=example-upnp
|
||||
EXAMPLE_UPNP_PATH=./cmd/$(EXAMPLE_UPNP_NAME)
|
||||
SCANNER_NAME=mdns-scanner
|
||||
SCANNER_PATH=./cmd/$(SCANNER_NAME)
|
||||
FAVICON_GEN_NAME=favicon-gen
|
||||
FAVICON_GEN_PATH=./cmd/$(FAVICON_GEN_NAME)
|
||||
BACKUP_NAME=soundtouch-backup
|
||||
BACKUP_PATH=./cmd/$(BACKUP_NAME)
|
||||
BUILD_DIR=./build
|
||||
|
||||
# Version info
|
||||
VERSION?=dev
|
||||
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||
COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
|
||||
# Linker flags
|
||||
LDFLAGS=-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.Commit=$(COMMIT)
|
||||
# Build flags: strip debug info/DWARF for smaller binaries, remove local paths for reproducibility
|
||||
BUILDFLAGS=-trimpath -ldflags="-s -w"
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-examples
|
||||
build: build-cli build-service build-web build-examples build-favicon-gen build-backup
|
||||
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(BINARY_PATH)
|
||||
|
||||
build-service:
|
||||
@echo "Building $(SERVICE_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
|
||||
|
||||
build-web:
|
||||
@echo "Building $(WEB_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
|
||||
|
||||
build-examples:
|
||||
@echo "Building $(EXAMPLE_MDNS_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME) $(EXAMPLE_MDNS_PATH)
|
||||
@echo "Building $(EXAMPLE_UPNP_NAME)..."
|
||||
$(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME) $(EXAMPLE_UPNP_PATH)
|
||||
@echo "Building $(SCANNER_NAME)..."
|
||||
$(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
|
||||
build-all: build-linux build-darwin build-windows build-examples-all
|
||||
build-favicon-gen:
|
||||
@echo "Building $(FAVICON_GEN_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
|
||||
|
||||
build-backup:
|
||||
@echo "Building $(BACKUP_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME) $(BACKUP_PATH)
|
||||
|
||||
build-all: build-linux build-linux-armv7 build-darwin build-windows build-examples-all
|
||||
|
||||
build-linux:
|
||||
@echo "Building for Linux..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-amd64 $(SERVICE_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-amd64 $(BACKUP_PATH)
|
||||
|
||||
build-linux-armv7:
|
||||
@echo "Building for Linux ARMv7 (CGO_ENABLED=0 for kernel 3.14+ compatibility)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-linux-armv7 $(SERVICE_PATH)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 $(BINARY_PATH)
|
||||
GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-linux-armv7 $(BACKUP_PATH)
|
||||
|
||||
build-darwin:
|
||||
@echo "Building for macOS..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(BINARY_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-amd64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-darwin-arm64 $(SERVICE_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-amd64 $(BACKUP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-darwin-arm64 $(BACKUP_PATH)
|
||||
|
||||
build-windows:
|
||||
@echo "Building for Windows..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(BINARY_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME)-windows-amd64.exe $(SERVICE_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(BACKUP_NAME)-windows-amd64.exe $(BACKUP_PATH)
|
||||
|
||||
build-examples-all:
|
||||
@echo "Building examples for all platforms..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-linux-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-amd64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-darwin-arm64 $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_MDNS_NAME)-windows-amd64.exe $(EXAMPLE_MDNS_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-linux-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-amd64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-darwin-arm64 $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(EXAMPLE_UPNP_NAME)-windows-amd64.exe $(EXAMPLE_UPNP_PATH)
|
||||
GOOS=linux GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-linux-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-amd64 $(SCANNER_PATH)
|
||||
GOOS=darwin GOARCH=arm64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-darwin-arm64 $(SCANNER_PATH)
|
||||
GOOS=windows GOARCH=amd64 $(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SCANNER_NAME)-windows-amd64.exe $(SCANNER_PATH)
|
||||
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
@@ -90,7 +128,58 @@ test-coverage:
|
||||
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report generated: coverage.html"
|
||||
|
||||
check: fmt vet test
|
||||
check: fmt vet test test-http-client
|
||||
|
||||
test-http-client:
|
||||
@echo "Starting services with docker compose..."
|
||||
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
|
||||
@echo "Waiting for services to start..."
|
||||
@sleep 10
|
||||
@echo "Running .http tests..."
|
||||
@docker run --rm --network soundtouch-test-net \
|
||||
-v "$(PWD)/tests/integration/http-client:/workdir" \
|
||||
jetbrains/intellij-http-client:2026.1 \
|
||||
--env-file /workdir/http-client.env.json \
|
||||
--env ci \
|
||||
/workdir/spotify_registration.http \
|
||||
/workdir/amazon_registration.http \
|
||||
/workdir/create_account.http \
|
||||
/workdir/register_device.http \
|
||||
/workdir/spotify_full_flow.http \
|
||||
/workdir/customer_support.http \
|
||||
/workdir/power_on.http \
|
||||
/workdir/get_bmx_services.http \
|
||||
/workdir/get_sourceproviders.http \
|
||||
/workdir/get_software_update.http \
|
||||
/workdir/get_soundtouch_updates.http \
|
||||
/workdir/get_streaming_token.http \
|
||||
/workdir/post_oauth_token.http \
|
||||
/workdir/post_oauth_token_amazon.http \
|
||||
/workdir/get_provider_settings.http \
|
||||
/workdir/tunein_playback_station.http \
|
||||
/workdir/set_preset_6.http \
|
||||
/workdir/get_presets.http \
|
||||
/workdir/delete_preset_6.http \
|
||||
/workdir/set_preset_5.http \
|
||||
/workdir/post_recent.http \
|
||||
/workdir/get_recents.http \
|
||||
/workdir/get_account_presets.http \
|
||||
/workdir/get_account_devices.http \
|
||||
/workdir/get_account_sources.http \
|
||||
/workdir/get_api_versions.http \
|
||||
/workdir/post_musicprovider_is_eligible.http \
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/create_group.http \
|
||||
/workdir/get_group.http \
|
||||
/workdir/rename_device.http \
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
EXIT_CODE=$$?; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs soundtouch-service; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs spotify-mock; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs amazon-mock; \
|
||||
docker compose -f docker-compose.yml -f docker-compose.ci.yml down; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
fmt:
|
||||
@echo "Formatting code..."
|
||||
@@ -113,6 +202,18 @@ dev: build-cli
|
||||
@echo "Starting development CLI..."
|
||||
$(BUILD_DIR)/$(BINARY_NAME) -help
|
||||
|
||||
dev-service: build-service
|
||||
@echo "Starting development service..."
|
||||
$(BUILD_DIR)/$(SERVICE_NAME)
|
||||
|
||||
dev-service-proxy: build-service
|
||||
@echo "Starting development service with proxy..."
|
||||
@if [ -z "$(PROXY_URL)" ]; then \
|
||||
echo "Usage: make dev-service-proxy PROXY_URL=http://localhost:8001"; \
|
||||
exit 1; \
|
||||
fi
|
||||
PYTHON_BACKEND_URL=$(PROXY_URL) $(BUILD_DIR)/$(SERVICE_NAME)
|
||||
|
||||
dev-discover: build-cli
|
||||
@echo "Running device discovery..."
|
||||
$(BUILD_DIR)/$(BINARY_NAME) -discover
|
||||
@@ -169,9 +270,44 @@ dev-scan-http: build-examples
|
||||
@echo "Scanning for HTTP mDNS services..."
|
||||
$(BUILD_DIR)/$(SCANNER_NAME) -service _http._tcp -v
|
||||
|
||||
install: build-cli
|
||||
@echo "Installing $(BINARY_NAME) to $(GOPATH)/bin..."
|
||||
dev-web: build-web
|
||||
@echo "Starting web UI (default port 8080)..."
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME)
|
||||
|
||||
dev-web-port: build-web
|
||||
@echo "Starting web UI on custom port..."
|
||||
@if [ -z "$(PORT)" ]; then \
|
||||
echo "Usage: make dev-web-port PORT=8888"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -port $(PORT)
|
||||
|
||||
dev-backup: build-backup
|
||||
@echo "Running backup tool..."
|
||||
$(BUILD_DIR)/$(BACKUP_NAME) --help
|
||||
|
||||
dev-backup-cloud: build-backup
|
||||
@echo "Running cloud backup..."
|
||||
$(BUILD_DIR)/$(BACKUP_NAME) cloud
|
||||
|
||||
dev-backup-local: build-backup
|
||||
@echo "Running local backup (auto-discover)..."
|
||||
$(BUILD_DIR)/$(BACKUP_NAME) local --discover
|
||||
|
||||
dev-web-host: build-web
|
||||
@echo "Starting web UI with specific host..."
|
||||
@if [ -z "$(HOST)" ]; then \
|
||||
echo "Usage: make dev-web-host HOST=192.168.1.10"; \
|
||||
exit 1; \
|
||||
fi
|
||||
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -host $(HOST)
|
||||
|
||||
install: build-cli build-service build-web build-backup
|
||||
@echo "Installing binaries to $(GOPATH)/bin..."
|
||||
cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(SERVICE_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/
|
||||
cp $(BUILD_DIR)/$(BACKUP_NAME) $(GOPATH)/bin/
|
||||
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
@@ -182,7 +318,7 @@ clean:
|
||||
release: clean check build-all
|
||||
@echo "Creating release archive..."
|
||||
@mkdir -p $(BUILD_DIR)/release
|
||||
@for binary in $(BUILD_DIR)/$(BINARY_NAME)-*; do \
|
||||
@for binary in $(BUILD_DIR)/$(BINARY_NAME)-* $(BUILD_DIR)/$(SERVICE_NAME)-*; do \
|
||||
if [ -f "$$binary" ]; then \
|
||||
cp "$$binary" $(BUILD_DIR)/release/; \
|
||||
fi \
|
||||
@@ -191,18 +327,31 @@ release: clean check build-all
|
||||
|
||||
docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker build -t soundtouch-go:$(VERSION) .
|
||||
docker build --target soundtouch-service -t soundtouch-service .
|
||||
|
||||
docker-dev: docker-build
|
||||
@echo "Running development container..."
|
||||
docker run --rm -it --network host soundtouch-go:$(VERSION)
|
||||
docker-run-host:
|
||||
@echo "Running Docker container..."
|
||||
@echo "Note: --network host is used for discovery (Linux only). For macOS/Windows use port mapping."
|
||||
docker run --rm -it --network host -v $$(pwd)/data:/app/data soundtouch-service
|
||||
|
||||
docker-run-ports:
|
||||
@echo "Running Docker container with port mapping (discovery will be manual)..."
|
||||
docker run --rm -it -p 8000:8000 -v $$(pwd)/data:/app/data soundtouch-service
|
||||
|
||||
screenshots:
|
||||
@echo "Capturing documentation screenshots..."
|
||||
@bash scripts/screenshots/run.sh
|
||||
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " build - Build the CLI tool and examples"
|
||||
@echo " build - Build the CLI tool, service, and examples"
|
||||
@echo " build-cli - Build only the CLI tool"
|
||||
@echo " build-service - Build only the service"
|
||||
@echo " build-backup - Build only the backup tool"
|
||||
@echo " build-favicon-gen - Build the favicon generator"
|
||||
@echo " build-examples - Build only the example programs"
|
||||
@echo " build-all - Build for all platforms"
|
||||
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
|
||||
@echo " test - Run tests"
|
||||
@echo " test-coverage - Run tests with coverage report"
|
||||
@echo " check - Run fmt, vet, and tests"
|
||||
@@ -211,6 +360,9 @@ help:
|
||||
@echo " lint - Run golangci-lint"
|
||||
@echo " tidy - Tidy dependencies"
|
||||
@echo " dev - Build and show CLI help"
|
||||
@echo " dev-service - Build and run service locally"
|
||||
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
|
||||
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
|
||||
@echo " dev-discover - Build and run device discovery"
|
||||
@echo " dev-info - Build and get device info (HOST=ip required)"
|
||||
@echo " dev-mdns - Build and run mDNS discovery example"
|
||||
@@ -222,14 +374,23 @@ help:
|
||||
@echo " dev-scan-all - Scan all mDNS services on network"
|
||||
@echo " dev-scan-soundtouch - Scan specifically for SoundTouch mDNS services"
|
||||
@echo " dev-scan-http - Scan for HTTP mDNS services"
|
||||
@echo " install - Install binary to GOPATH/bin"
|
||||
@echo " dev-backup - Build and show backup tool help"
|
||||
@echo " dev-backup-cloud - Build and run cloud backup (prompts for credentials)"
|
||||
@echo " dev-backup-local - Build and run local backup (auto-discover speakers)"
|
||||
@echo " dev-web - Build and run web UI (default port 8080)"
|
||||
@echo " dev-web-port - Build and run web UI on custom port (PORT=8888)"
|
||||
@echo " dev-web-host - Build and run web UI with specific device (HOST=ip)"
|
||||
@echo " install - Install binaries to GOPATH/bin"
|
||||
@echo " clean - Clean build artifacts"
|
||||
@echo " release - Create release binaries"
|
||||
@echo " docker-build - Build Docker image"
|
||||
@echo " docker-dev - Run development container"
|
||||
@echo " docker-run-host - Run container with host networking (Linux discovery)"
|
||||
@echo " docker-run-ports - Run container with port mapping (macOS/Windows/No discovery)"
|
||||
@echo " help - Show this help message"
|
||||
@echo ""
|
||||
@echo "Examples:"
|
||||
@echo " make dev-service"
|
||||
@echo " make dev-service-proxy PROXY_URL=http://192.168.1.50:8001"
|
||||
@echo " make dev-discover"
|
||||
@echo " make dev-info HOST=192.168.1.10"
|
||||
@echo " make dev-mdns"
|
||||
@@ -240,5 +401,8 @@ help:
|
||||
@echo " make dev-upnp-timeout TIMEOUT=10s"
|
||||
@echo " make dev-scan-all"
|
||||
@echo " make dev-scan-soundtouch"
|
||||
@echo " make dev-web"
|
||||
@echo " make dev-web-port PORT=8888"
|
||||
@echo " make dev-web-host HOST=192.168.1.10"
|
||||
@echo " make test"
|
||||
@echo " make build-all"
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// Package main provides a debug tool for analyzing device consolidation and migration scenarios.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("Usage: debug-consolidation <data-directory>")
|
||||
fmt.Println("Example: debug-consolidation /var/lib/soundtouch-service")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dataDir := os.Args[1]
|
||||
|
||||
fmt.Printf("🔍 Analyzing device consolidation in: %s\n", dataDir)
|
||||
|
||||
// Initialize datastore
|
||||
ds := datastore.NewDataStore(dataDir)
|
||||
|
||||
// List all devices
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to list devices: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("📱 Found %d device entries:\n", len(devices))
|
||||
|
||||
for i := range devices {
|
||||
device := &devices[i]
|
||||
fmt.Printf(" %d. %s (Account: %s)\n", i+1, device.DeviceID, device.AccountID)
|
||||
fmt.Printf(" Name: %s\n", device.Name)
|
||||
fmt.Printf(" IP: %s, MAC: %s, Serial: %s\n",
|
||||
device.IPAddress, device.MacAddress, device.DeviceSerialNumber)
|
||||
|
||||
// Check directory contents
|
||||
deviceDir := ds.AccountDeviceDir(device.AccountID, device.DeviceID)
|
||||
analyzeDeviceDirectory(deviceDir, device.DeviceID)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Group devices by potential physical device
|
||||
fmt.Println("🔄 Analyzing potential consolidation opportunities:")
|
||||
|
||||
deviceGroups := groupDevicesByIdentity(devices)
|
||||
|
||||
for i, group := range deviceGroups {
|
||||
if len(group) <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(" Group %d - %d entries for same physical device:\n", i+1, len(group))
|
||||
|
||||
for i := range group {
|
||||
device := &group[i]
|
||||
deviceDir := ds.AccountDeviceDir(device.AccountID, device.DeviceID)
|
||||
fileCount := countFiles(deviceDir)
|
||||
fmt.Printf(" - %s (%d files)\n", device.DeviceID, fileCount)
|
||||
}
|
||||
|
||||
// Recommend consolidation target
|
||||
macDevice := findMACBasedDevice(group)
|
||||
if macDevice != nil {
|
||||
fmt.Printf(" → Recommend keeping: %s (MAC-based)\n", macDevice.DeviceID)
|
||||
} else {
|
||||
fmt.Printf(" → No clear MAC-based target found\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeDeviceDirectory(dirPath, deviceID string) {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
fmt.Printf(" Directory: %s (Error: %v)\n", dirPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Directory: %s (%d files)\n", dirPath, len(entries))
|
||||
|
||||
// Check for important files
|
||||
importantFiles := []string{"DeviceInfo.xml", "Presets.xml", "Recents.xml", "Sources.xml"}
|
||||
for _, fileName := range importantFiles {
|
||||
filePath := filepath.Join(dirPath, fileName)
|
||||
if stat, err := os.Stat(filePath); err == nil {
|
||||
status := "✓"
|
||||
if stat.Size() == 0 {
|
||||
status = "⚠️ (empty)"
|
||||
} else if stat.Size() < 100 {
|
||||
status = "⚠️ (very small)"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s (%d bytes)\n", status, fileName, stat.Size())
|
||||
} else {
|
||||
fmt.Printf(" ❌ %s (missing)\n", fileName)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if deviceID looks like MAC address
|
||||
if isLikelyMACAddress(deviceID) {
|
||||
fmt.Printf(" 📍 Device ID appears to be MAC address format\n")
|
||||
} else {
|
||||
fmt.Printf(" 📍 Device ID appears to be %s format\n", guessIDType(deviceID))
|
||||
}
|
||||
}
|
||||
|
||||
func countFiles(dirPath string) int {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
count := 0
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
func groupDevicesByIdentity(devices []models.ServiceDeviceInfo) [][]models.ServiceDeviceInfo {
|
||||
var groups [][]models.ServiceDeviceInfo
|
||||
|
||||
// Simple grouping by MAC address and serial number
|
||||
macGroups := make(map[string][]models.ServiceDeviceInfo)
|
||||
serialGroups := make(map[string][]models.ServiceDeviceInfo)
|
||||
ipGroups := make(map[string][]models.ServiceDeviceInfo)
|
||||
|
||||
for i := range devices {
|
||||
device := &devices[i]
|
||||
// Group by MAC address
|
||||
if device.MacAddress != "" {
|
||||
macGroups[device.MacAddress] = append(macGroups[device.MacAddress], *device)
|
||||
}
|
||||
|
||||
// Group by serial number
|
||||
if device.DeviceSerialNumber != "" {
|
||||
serialGroups[device.DeviceSerialNumber] = append(serialGroups[device.DeviceSerialNumber], *device)
|
||||
}
|
||||
|
||||
// Group by IP address
|
||||
if device.IPAddress != "" {
|
||||
ipGroups[device.IPAddress] = append(ipGroups[device.IPAddress], *device)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge groups - prioritize MAC address grouping
|
||||
processed := make(map[string]bool)
|
||||
|
||||
for _, macDevices := range macGroups {
|
||||
if len(macDevices) > 1 {
|
||||
groups = append(groups, macDevices)
|
||||
for i := range macDevices {
|
||||
processed[macDevices[i].DeviceID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for serial number groups not already processed
|
||||
for _, serialDevices := range serialGroups {
|
||||
if len(serialDevices) > 1 {
|
||||
unprocessed := []models.ServiceDeviceInfo{}
|
||||
|
||||
for i := range serialDevices {
|
||||
if !processed[serialDevices[i].DeviceID] {
|
||||
unprocessed = append(unprocessed, serialDevices[i])
|
||||
}
|
||||
}
|
||||
|
||||
if len(unprocessed) > 1 {
|
||||
groups = append(groups, unprocessed)
|
||||
for i := range unprocessed {
|
||||
processed[unprocessed[i].DeviceID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
func findMACBasedDevice(devices []models.ServiceDeviceInfo) *models.ServiceDeviceInfo {
|
||||
for i := range devices {
|
||||
if isLikelyMACAddress(devices[i].DeviceID) {
|
||||
return &devices[i]
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLikelyMACAddress(id string) bool {
|
||||
// MAC addresses are typically 12 hex characters without separators
|
||||
// or 17 characters with separators (XX:XX:XX:XX:XX:XX)
|
||||
if len(id) == 12 {
|
||||
for _, c := range id {
|
||||
if (c < '0' || c > '9') && (c < 'A' || c > 'F') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func guessIDType(id string) string {
|
||||
if len(id) > 15 && (id[0] == 'I' || id[0] == 'K') {
|
||||
return "serial number"
|
||||
}
|
||||
|
||||
// Check if it looks like an IP address
|
||||
if len(id) >= 7 && len(id) <= 15 {
|
||||
dotCount := 0
|
||||
|
||||
for _, c := range id {
|
||||
if c == '.' {
|
||||
dotCount++
|
||||
} else if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dotCount == 3 {
|
||||
return "IP address"
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Command dummy-speaker runs an HTTP-only fake SoundTouch speaker and
|
||||
// optionally registers it with a running soundtouch-service so the web UI
|
||||
// has a device to display.
|
||||
//
|
||||
// Intended for documentation screenshots and local UI smoke checks. Do not
|
||||
// use against a real network — the fixture payload is synthetic and would
|
||||
// confuse other tooling that expects live device data.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// dummy-speaker --port 8090 --register http://localhost:8000
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/testing/fakespeaker"
|
||||
)
|
||||
|
||||
func main() {
|
||||
listen := flag.String("listen", "127.0.0.1:8090", "bind address for the fake speaker's HTTP API")
|
||||
telnetListen := flag.String("telnet-listen", "127.0.0.1:17000", "bind address for the fake speaker's telnet diagnostic shell (empty to disable)")
|
||||
register := flag.String("register", "", "service base URL (e.g. http://localhost:8000) to self-register with via POST /setup/devices")
|
||||
registerAs := flag.String("register-as", "", "address to send to /setup/devices (defaults to --listen)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
s, err := fakespeaker.Start(fakespeaker.Config{
|
||||
HTTPListen: *listen,
|
||||
TelnetListen: *telnetListen,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("start fake speaker: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
|
||||
|
||||
if addr := s.TelnetAddr(); addr != "" {
|
||||
log.Printf("fake speaker telnet listening on tcp://%s", addr)
|
||||
}
|
||||
|
||||
if *register != "" {
|
||||
target := *registerAs
|
||||
if target == "" {
|
||||
target = s.HTTPAddr()
|
||||
}
|
||||
|
||||
if err := registerWithService(*register, target); err != nil {
|
||||
log.Printf("self-register failed: %v (continuing anyway)", err)
|
||||
} else {
|
||||
log.Printf("registered %s with service at %s", target, *register)
|
||||
}
|
||||
}
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sig
|
||||
|
||||
log.Printf("shutting down")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.Stop(ctx); err != nil {
|
||||
log.Printf("stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerWithService(serviceURL, deviceAddr string) error {
|
||||
body, err := json.Marshal(map[string]string{"ip": deviceAddr})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, serviceURL+"/setup/devices", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("service responded %s", resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -94,7 +94,12 @@ func main() {
|
||||
fmt.Printf(" Host: %s\n", device.Host)
|
||||
fmt.Printf(" Port: %d\n", device.Port)
|
||||
fmt.Printf(" API URL: http://%s:%d/\n", device.Host, device.Port)
|
||||
fmt.Printf(" Location: %s\n", device.Location)
|
||||
fmt.Printf(" Info URL: %s\n", device.InfoURL)
|
||||
|
||||
if device.MDNSHostname != "" {
|
||||
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
|
||||
}
|
||||
|
||||
fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Package main provides an example of discovering SoundTouch devices using all three mechanisms.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
)
|
||||
|
||||
func main() {
|
||||
verbose := flag.Bool("verbose", false, "Enable verbose logging")
|
||||
timeout := flag.Duration("timeout", 5*time.Second, "Discovery timeout")
|
||||
showConfig := flag.Bool("show-config", false, "Show configuration details")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Configure logging
|
||||
if *verbose {
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||
} else {
|
||||
log.SetOutput(os.Stderr)
|
||||
}
|
||||
|
||||
fmt.Println("SoundTouch Unified Discovery Example")
|
||||
fmt.Println("===================================")
|
||||
|
||||
fmt.Printf("Timeout: %v, Verbose: %v\n", *timeout, *verbose)
|
||||
fmt.Println()
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to load configuration: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Override timeout from command line
|
||||
cfg.DiscoveryTimeout = *timeout
|
||||
|
||||
if *showConfig {
|
||||
printConfiguration(cfg)
|
||||
}
|
||||
|
||||
fmt.Println("Testing individual discovery mechanisms:")
|
||||
fmt.Println("--------------------------------------")
|
||||
|
||||
testSSDP(cfg, *timeout, *verbose)
|
||||
testMDNS(cfg, *timeout, *verbose)
|
||||
testConfig(cfg, *verbose)
|
||||
testUnified(cfg, *timeout, *verbose)
|
||||
}
|
||||
|
||||
func printConfiguration(cfg *config.Config) {
|
||||
fmt.Println("Configuration:")
|
||||
fmt.Printf(" UPnP Enabled: %v\n", cfg.UPnPEnabled)
|
||||
fmt.Printf(" mDNS Enabled: %v\n", cfg.MDNSEnabled)
|
||||
fmt.Printf(" Cache Enabled: %v\n", cfg.CacheEnabled)
|
||||
fmt.Printf(" Discovery Timeout: %v\n", cfg.DiscoveryTimeout)
|
||||
fmt.Printf(" Preferred Devices: %d\n", len(cfg.PreferredDevices))
|
||||
|
||||
for i, device := range cfg.PreferredDevices {
|
||||
fmt.Printf(" %d. %s at %s:%d\n", i+1, device.Name, device.Host, device.Port)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func testSSDP(cfg *config.Config, timeout time.Duration, verbose bool) {
|
||||
// Test SSDP discovery
|
||||
fmt.Println("1. SSDP/UPnP Discovery:")
|
||||
|
||||
if cfg.UPnPEnabled {
|
||||
// Create fresh context for SSDP test
|
||||
ssdpCtx, ssdpCancel := context.WithTimeout(context.Background(), timeout+2*time.Second)
|
||||
defer ssdpCancel()
|
||||
|
||||
ssdpService := discovery.NewServiceWithConfig(cfg)
|
||||
start := time.Now()
|
||||
ssdpDevices, ssdpErr := ssdpService.DiscoverDevices(ssdpCtx)
|
||||
duration := time.Since(start)
|
||||
|
||||
if ssdpErr != nil {
|
||||
fmt.Printf(" Error: %v\n", ssdpErr)
|
||||
} else {
|
||||
fmt.Printf(" Found %d devices in %v\n", len(ssdpDevices), duration)
|
||||
|
||||
for _, device := range ssdpDevices {
|
||||
fmt.Printf(" - %s (%s:%d)\n", device.Name, device.Host, device.Port)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" Info URL: %s\n", device.InfoURL)
|
||||
|
||||
if device.UPnPLocation != "" {
|
||||
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" Disabled in configuration")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func testMDNS(cfg *config.Config, timeout time.Duration, verbose bool) {
|
||||
// Test mDNS discovery
|
||||
fmt.Println("2. mDNS/Bonjour Discovery:")
|
||||
|
||||
if cfg.MDNSEnabled {
|
||||
// Create fresh context for mDNS test
|
||||
mdnsCtx, mdnsCancel := context.WithTimeout(context.Background(), timeout+2*time.Second)
|
||||
defer mdnsCancel()
|
||||
|
||||
mdnsService := discovery.NewMDNSDiscoveryService(timeout)
|
||||
start := time.Now()
|
||||
mdnsDevices, mdnsErr := mdnsService.DiscoverDevices(mdnsCtx)
|
||||
duration := time.Since(start)
|
||||
|
||||
if mdnsErr != nil {
|
||||
fmt.Printf(" Error: %v\n", mdnsErr)
|
||||
} else {
|
||||
fmt.Printf(" Found %d devices in %v\n", len(mdnsDevices), duration)
|
||||
|
||||
for _, device := range mdnsDevices {
|
||||
fmt.Printf(" - %s (%s:%d)\n", device.Name, device.Host, device.Port)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" Info URL: %s\n", device.InfoURL)
|
||||
|
||||
if device.MDNSHostname != "" {
|
||||
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" Disabled in configuration")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func testConfig(cfg *config.Config, verbose bool) {
|
||||
// Test configuration-based devices
|
||||
fmt.Println("3. Configuration-based Devices:")
|
||||
|
||||
configDevices := cfg.GetPreferredDevicesAsDiscovered()
|
||||
if len(configDevices) > 0 {
|
||||
fmt.Printf(" Found %d configured devices\n", len(configDevices))
|
||||
|
||||
for _, device := range configDevices {
|
||||
fmt.Printf(" - %s (%s:%d)\n", device.Name, device.Host, device.Port)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" Info URL: %s\n", device.InfoURL)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" No devices configured in .env file")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func testUnified(cfg *config.Config, timeout time.Duration, verbose bool) {
|
||||
// Test unified discovery
|
||||
fmt.Println("4. Unified Discovery (combines all methods):")
|
||||
// Create fresh context for unified test
|
||||
unifiedCtx, unifiedCancel := context.WithTimeout(context.Background(), timeout+2*time.Second)
|
||||
defer unifiedCancel()
|
||||
|
||||
unifiedService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
start := time.Now()
|
||||
allDevices, err := unifiedService.DiscoverDevices(unifiedCtx)
|
||||
duration := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf(" Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Found %d total devices in %v\n", len(allDevices), duration)
|
||||
fmt.Println()
|
||||
|
||||
if len(allDevices) == 0 {
|
||||
fmt.Println("No SoundTouch devices found via any discovery method")
|
||||
fmt.Println()
|
||||
fmt.Println("This could mean:")
|
||||
fmt.Println("- No SoundTouch devices on network")
|
||||
fmt.Println("- All discovery methods are disabled")
|
||||
fmt.Println("- Network blocks multicast traffic")
|
||||
fmt.Println("- Devices are not advertising services")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Unified Device List:")
|
||||
fmt.Println("-------------------")
|
||||
|
||||
for i, device := range allDevices {
|
||||
fmt.Printf("%d. %s\n", i+1, device.Name)
|
||||
fmt.Printf(" Host: %s\n", device.Host)
|
||||
fmt.Printf(" Port: %d\n", device.Port)
|
||||
fmt.Printf(" API Base URL: %s\n", device.APIBaseURL)
|
||||
fmt.Printf(" Info URL: %s\n", device.InfoURL)
|
||||
fmt.Printf(" Discovery Method: %s\n", device.DiscoveryMethod)
|
||||
fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
|
||||
|
||||
if verbose {
|
||||
if device.ModelID != "" {
|
||||
fmt.Printf(" Model ID: %s\n", device.ModelID)
|
||||
}
|
||||
|
||||
if device.SerialNo != "" {
|
||||
fmt.Printf(" Serial No: %s\n", device.SerialNo)
|
||||
}
|
||||
|
||||
// Show protocol-specific details
|
||||
if device.UPnPLocation != "" {
|
||||
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
|
||||
|
||||
if device.UPnPUSN != "" {
|
||||
fmt.Printf(" UPnP USN: %s\n", device.UPnPUSN)
|
||||
}
|
||||
}
|
||||
|
||||
if device.MDNSHostname != "" {
|
||||
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
|
||||
|
||||
if device.MDNSService != "" {
|
||||
fmt.Printf(" mDNS Service: %s\n", device.MDNSService)
|
||||
}
|
||||
}
|
||||
|
||||
if device.ConfigName != "" {
|
||||
fmt.Printf(" Config Name: %s\n", device.ConfigName)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Unified discovery completed successfully!\n")
|
||||
fmt.Printf("✓ Found %d unique device(s) in %v\n", len(allDevices), duration)
|
||||
|
||||
if verbose {
|
||||
fmt.Println()
|
||||
fmt.Println("Technical Details:")
|
||||
fmt.Printf("- SSDP multicast address: 239.255.255.250:1900\n")
|
||||
fmt.Printf("- mDNS service type: _soundtouch._tcp.local\n")
|
||||
fmt.Printf("- Discovery timeout: %v\n", timeout)
|
||||
fmt.Printf("- Configuration file: .env (if present)\n")
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,12 @@ func main() {
|
||||
fmt.Printf(" Host: %s\n", device.Host)
|
||||
fmt.Printf(" Port: %d\n", device.Port)
|
||||
fmt.Printf(" API URL: http://%s:%d/\n", device.Host, device.Port)
|
||||
fmt.Printf(" Location: %s\n", device.Location)
|
||||
fmt.Printf(" Info URL: %s\n", device.InfoURL)
|
||||
|
||||
if device.UPnPLocation != "" {
|
||||
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
|
||||
}
|
||||
|
||||
fmt.Printf(" Last seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Package main provides a utility to generate PNG and ICO favicons from SVG source files.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/srwiley/oksvg"
|
||||
"github.com/srwiley/rasterx"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mediaDir := "pkg/service/handlers/web/img"
|
||||
files := []string{"favicon-braille", "favicon-morse"}
|
||||
|
||||
for _, name := range files {
|
||||
svgPath := filepath.Join(mediaDir, name+".svg")
|
||||
pngPath := filepath.Join(mediaDir, name+".png")
|
||||
icoPath := filepath.Join(mediaDir, name+".ico")
|
||||
|
||||
fmt.Printf("Processing %s...\n", name)
|
||||
|
||||
// 1. Render SVG to PNG
|
||||
img, err := renderSVG(svgPath, 32, 32)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to render %s: %v", svgPath, err)
|
||||
}
|
||||
|
||||
f, err := os.Create(pngPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create %s: %v", pngPath, err)
|
||||
}
|
||||
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
f.Close()
|
||||
log.Fatalf("Failed to encode PNG %s: %v", pngPath, err)
|
||||
}
|
||||
|
||||
f.Close()
|
||||
fmt.Printf("Created %s\n", pngPath)
|
||||
|
||||
// 2. Create ICO (containing multiple sizes)
|
||||
sizes := []int{16, 32, 48}
|
||||
|
||||
var images []image.Image
|
||||
|
||||
for _, s := range sizes {
|
||||
m, err := renderSVG(svgPath, s, s)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to render %s at size %d: %v", svgPath, s, err)
|
||||
}
|
||||
|
||||
images = append(images, m)
|
||||
}
|
||||
|
||||
if err := writeICO(icoPath, images); err != nil {
|
||||
log.Fatalf("Failed to write ICO %s: %v", icoPath, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created %s\n", icoPath)
|
||||
}
|
||||
}
|
||||
|
||||
func renderSVG(path string, w, h int) (image.Image, error) {
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
icon, err := oksvg.ReadIconStream(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
icon.SetTarget(0, 0, float64(w), float64(h))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
gv := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
|
||||
dasher := rasterx.NewDasher(w, h, gv)
|
||||
icon.Draw(dasher, 1.0)
|
||||
|
||||
return rgba, nil
|
||||
}
|
||||
|
||||
// Simple ICO encoder that wraps PNGs
|
||||
func writeICO(path string, images []image.Image) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
bw := bufio.NewWriter(f)
|
||||
defer bw.Flush()
|
||||
|
||||
// ICONDIR header
|
||||
// Reserved (2), Type (2), Count (2)
|
||||
binary.Write(bw, binary.LittleEndian, uint16(0))
|
||||
binary.Write(bw, binary.LittleEndian, uint16(1)) // 1 = ICO
|
||||
binary.Write(bw, binary.LittleEndian, uint16(len(images)))
|
||||
|
||||
var pngData [][]byte
|
||||
|
||||
for _, img := range images {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pngData = append(pngData, buf.Bytes())
|
||||
}
|
||||
|
||||
offset := uint32(6 + len(images)*16)
|
||||
for i, img := range images {
|
||||
b := img.Bounds()
|
||||
|
||||
width := uint8(b.Dx())
|
||||
if b.Dx() >= 256 {
|
||||
width = 0
|
||||
}
|
||||
|
||||
height := uint8(b.Dy())
|
||||
if b.Dy() >= 256 {
|
||||
height = 0
|
||||
}
|
||||
|
||||
// ICONDIRENTRY
|
||||
bw.WriteByte(width)
|
||||
bw.WriteByte(height)
|
||||
bw.WriteByte(0) // Color count
|
||||
bw.WriteByte(0) // Reserved
|
||||
binary.Write(bw, binary.LittleEndian, uint16(1)) // Planes (1)
|
||||
binary.Write(bw, binary.LittleEndian, uint16(32)) // Bits per pixel (32)
|
||||
binary.Write(bw, binary.LittleEndian, uint32(len(pngData[i])))
|
||||
binary.Write(bw, binary.LittleEndian, offset)
|
||||
|
||||
offset += uint32(len(pngData[i]))
|
||||
}
|
||||
|
||||
for _, data := range pngData {
|
||||
bw.Write(data)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+68
-60
@@ -14,6 +14,72 @@ import (
|
||||
"github.com/hashicorp/mdns"
|
||||
)
|
||||
|
||||
func displayResults(services []ServiceInfo) {
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No services found.")
|
||||
fmt.Println()
|
||||
fmt.Println("This could mean:")
|
||||
fmt.Println("- No mDNS services on network")
|
||||
fmt.Println("- Network blocks multicast traffic")
|
||||
fmt.Println("- Firewall blocks mDNS port 5353")
|
||||
fmt.Println("- Try different service types or increase timeout")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Group services by type for better display
|
||||
serviceGroups := make(map[string][]ServiceInfo)
|
||||
for _, s := range services {
|
||||
serviceGroups[s.ServiceType] = append(serviceGroups[s.ServiceType], s)
|
||||
}
|
||||
|
||||
// Display grouped services
|
||||
for serviceType, serviceList := range serviceGroups {
|
||||
fmt.Printf("Service Type: %s\n", serviceType)
|
||||
fmt.Printf(" Found %d instance(s):\n", len(serviceList))
|
||||
|
||||
for i, s := range serviceList {
|
||||
fmt.Printf(" %d. %s\n", i+1, s.Name)
|
||||
|
||||
if s.Host != "" {
|
||||
fmt.Printf(" Host: %s\n", s.Host)
|
||||
}
|
||||
|
||||
if s.IPv4 != "" {
|
||||
fmt.Printf(" IPv4: %s\n", s.IPv4)
|
||||
}
|
||||
|
||||
if s.IPv6 != "" {
|
||||
fmt.Printf(" IPv6: %s\n", s.IPv6)
|
||||
}
|
||||
|
||||
if s.Port > 0 {
|
||||
fmt.Printf(" Port: %d\n", s.Port)
|
||||
}
|
||||
|
||||
if len(s.TxtRecords) > 0 {
|
||||
fmt.Printf(" TXT Records: %v\n", s.TxtRecords)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func showSuggestions(service string) {
|
||||
if service == "_services._dns-sd._udp" {
|
||||
fmt.Println("Common services to look for SoundTouch devices:")
|
||||
fmt.Println("- _soundtouch._tcp.local.")
|
||||
fmt.Println("- _http._tcp.local.")
|
||||
fmt.Println("- _upnp._tcp.local.")
|
||||
fmt.Println("- _device-info._tcp.local.")
|
||||
fmt.Println()
|
||||
fmt.Println("Try scanning specific services:")
|
||||
fmt.Println(" ./mdns-scanner -service _soundtouch._tcp -v")
|
||||
fmt.Println(" ./mdns-scanner -service _http._tcp -v")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
verbose := flag.Bool("verbose", false, "Enable verbose logging")
|
||||
timeout := flag.Duration("timeout", 10*time.Second, "Discovery timeout")
|
||||
@@ -107,68 +173,10 @@ done:
|
||||
})
|
||||
|
||||
// Display results
|
||||
if len(services) == 0 {
|
||||
fmt.Println("No services found.")
|
||||
fmt.Println()
|
||||
fmt.Println("This could mean:")
|
||||
fmt.Println("- No mDNS services on network")
|
||||
fmt.Println("- Network blocks multicast traffic")
|
||||
fmt.Println("- Firewall blocks mDNS port 5353")
|
||||
fmt.Println("- Try different service types or increase timeout")
|
||||
} else {
|
||||
// Group services by type for better display
|
||||
serviceGroups := make(map[string][]ServiceInfo)
|
||||
|
||||
for _, service := range services {
|
||||
serviceType := service.ServiceType
|
||||
serviceGroups[serviceType] = append(serviceGroups[serviceType], service)
|
||||
}
|
||||
|
||||
// Display grouped services
|
||||
for serviceType, serviceList := range serviceGroups {
|
||||
fmt.Printf("Service Type: %s\n", serviceType)
|
||||
fmt.Printf(" Found %d instance(s):\n", len(serviceList))
|
||||
|
||||
for i, service := range serviceList {
|
||||
fmt.Printf(" %d. %s\n", i+1, service.Name)
|
||||
|
||||
if service.Host != "" {
|
||||
fmt.Printf(" Host: %s\n", service.Host)
|
||||
}
|
||||
|
||||
if service.IPv4 != "" {
|
||||
fmt.Printf(" IPv4: %s\n", service.IPv4)
|
||||
}
|
||||
|
||||
if service.IPv6 != "" {
|
||||
fmt.Printf(" IPv6: %s\n", service.IPv6)
|
||||
}
|
||||
|
||||
if service.Port > 0 {
|
||||
fmt.Printf(" Port: %d\n", service.Port)
|
||||
}
|
||||
|
||||
if len(service.TxtRecords) > 0 {
|
||||
fmt.Printf(" TXT Records: %v\n", service.TxtRecords)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
displayResults(services)
|
||||
|
||||
// Show suggestions for common SoundTouch-related services
|
||||
if *service == "_services._dns-sd._udp" {
|
||||
fmt.Println("Common services to look for SoundTouch devices:")
|
||||
fmt.Println("- _soundtouch._tcp.local.")
|
||||
fmt.Println("- _http._tcp.local.")
|
||||
fmt.Println("- _upnp._tcp.local.")
|
||||
fmt.Println("- _device-info._tcp.local.")
|
||||
fmt.Println()
|
||||
fmt.Println("Try scanning specific services:")
|
||||
fmt.Println(" ./mdns-scanner -service _soundtouch._tcp -v")
|
||||
fmt.Println(" ./mdns-scanner -service _http._tcp -v")
|
||||
}
|
||||
showSuggestions(*service)
|
||||
}
|
||||
|
||||
type ServiceInfo struct {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package main provides a mock Amazon LWA server for testing purposes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/testutils/amazon"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "Port to listen on")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Starting mock Amazon LWA server on port %d", *port)
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), amazon.NewAmazonHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package main provides a mock Spotify server for testing purposes.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/testutils/spotify"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.Int("port", 8080, "Port to listen on")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Starting mock Spotify server on port %d", *port)
|
||||
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), spotify.NewSpotifyHandler()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
# soundtouch-backup
|
||||
|
||||
A standalone tool for backing up Bose SoundTouch data — both your **cloud account** (presets, devices, sources) and the **local filesystem** of each speaker — before the Bose cloud services shut down on May 6, 2026.
|
||||
|
||||
## Overview
|
||||
|
||||
| Subcommand | What it backs up |
|
||||
|------------|----------------------------------------------------------------------------------------------------|
|
||||
| `all` | Cloud account **and** all paired speakers in one step — the recommended starting point |
|
||||
| `cloud` | Bose account profile, paired devices, cloud presets, music service sources |
|
||||
| `local` | Speaker HTTP API data (presets, sources, volume, …) and optionally device filesystem files via SSH |
|
||||
|
||||
Output is a single `.tar.gz` archive (or `.zip`) with a dated root directory.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
make build-backup
|
||||
# binary: ./build/soundtouch-backup
|
||||
```
|
||||
|
||||
Or install alongside the other tools:
|
||||
|
||||
```bash
|
||||
make install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Combined backup (recommended)
|
||||
|
||||
The `all` command is the simplest way to capture everything: it authenticates with the Bose cloud, backs up your account data, then reads the IP addresses from `devices.xml` and backs up each reachable speaker over HTTP.
|
||||
|
||||
```bash
|
||||
# Interactive — prompts for email and password
|
||||
soundtouch-backup all
|
||||
|
||||
# Non-interactive
|
||||
soundtouch-backup all --email you@example.com --password secret
|
||||
|
||||
# Include SSH filesystem backup for each speaker
|
||||
soundtouch-backup all --ssh
|
||||
|
||||
# Environment variables
|
||||
BOSE_EMAIL=you@example.com BOSE_PASSWORD=secret soundtouch-backup all --ssh
|
||||
```
|
||||
|
||||
**Flags**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|--------------|--------|---------------------------------------|--------------------------------------------------------|
|
||||
| `--email` | `-e` | — | Bose account email (`$BOSE_EMAIL`) |
|
||||
| `--password` | `--pw` | — | Bose account password (`$BOSE_PASSWORD`) |
|
||||
| `--ssh` | | on | Also capture filesystem files via SSH for each speaker |
|
||||
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path |
|
||||
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
|
||||
|
||||
Speakers that are offline or unreachable at the time of backup are skipped with a `✗` warning; the cloud data is still saved.
|
||||
|
||||
---
|
||||
|
||||
### Cloud backup
|
||||
|
||||
Backs up data from your Bose account at `streaming.bose.com`. Credentials are prompted interactively if not supplied as flags.
|
||||
|
||||
```bash
|
||||
# Interactive — prompts for email, masked password input
|
||||
soundtouch-backup cloud
|
||||
|
||||
# Non-interactive
|
||||
soundtouch-backup cloud --email you@example.com --password secret
|
||||
|
||||
# Environment variables (avoids secrets in shell history)
|
||||
BOSE_EMAIL=you@example.com BOSE_PASSWORD=secret soundtouch-backup cloud
|
||||
|
||||
# Zip output
|
||||
soundtouch-backup cloud --format zip --output my-bose-cloud.zip
|
||||
```
|
||||
|
||||
**Flags**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|--------------|--------|---------------------------------------|---------------------------------------------------|
|
||||
| `--email` | `-e` | — | Bose account email (`$BOSE_EMAIL`) |
|
||||
| `--password` | `--pw` | — | Bose account password (`$BOSE_PASSWORD`) |
|
||||
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path (`$SOUNDTOUCH_BACKUP_OUTPUT`) |
|
||||
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
|
||||
|
||||
**What gets fetched**
|
||||
|
||||
| File in archive | Source endpoint |
|
||||
|--------------------------|---------------------------------------------------------------------------------|
|
||||
| `cloud/emailaddress.xml` | `GET /streaming/account/{id}/emailaddress` |
|
||||
| `cloud/devices.xml` | `GET /streaming/account/{id}/devices` |
|
||||
| `cloud/sources.xml` | `GET /streaming/account/{id}/sources` |
|
||||
| `cloud/presets.xml` | `GET /streaming/account/{id}/presets/all` |
|
||||
| `cloud/full.xml` | `GET /streaming/account/{id}/full` (may overlap with the above; skipped if 4xx) |
|
||||
|
||||
---
|
||||
|
||||
### Local backup
|
||||
|
||||
Backs up each speaker over its HTTP API on port 8090. With `--ssh`, also captures key filesystem files via SSH.
|
||||
|
||||
```bash
|
||||
# Auto-discover all speakers on the local network
|
||||
soundtouch-backup local
|
||||
|
||||
# Specific speaker
|
||||
soundtouch-backup local --host 192.168.178.28
|
||||
|
||||
# Multiple speakers
|
||||
soundtouch-backup local --host 192.168.178.28 --host 192.168.178.35
|
||||
|
||||
# Include SSH filesystem backup
|
||||
soundtouch-backup local --ssh
|
||||
|
||||
# Longer discovery window on busy networks
|
||||
soundtouch-backup local --discover-timeout 10s
|
||||
```
|
||||
|
||||
**Flags**
|
||||
|
||||
| Flag | Short | Default | Description |
|
||||
|----------------------|-------|---------------------------------------|--------------------------------------------------|
|
||||
| `--host` | `-H` | — | Speaker host/IP, repeatable (`$SOUNDTOUCH_HOST`) |
|
||||
| `--port` | `-p` | `8090` | Speaker HTTP port (`$SOUNDTOUCH_PORT`) |
|
||||
| `--discover` | `-d` | auto | Force mDNS/UPnP discovery |
|
||||
| `--discover-timeout` | | `5s` | Discovery timeout |
|
||||
| `--ssh` | | on | Also capture filesystem files via SSH |
|
||||
| `--output` | `-o` | `soundtouch-backup-YYYY-MM-DD.tar.gz` | Output archive path |
|
||||
| `--format` | | `tar.gz` | Archive format: `tar.gz` or `zip` |
|
||||
|
||||
**What gets fetched via HTTP**
|
||||
|
||||
| File | Device endpoint |
|
||||
|---------------------|-----------------|
|
||||
| `info.xml` | `/info` |
|
||||
| `name.xml` | `/name` |
|
||||
| `presets.xml` | `/presets` |
|
||||
| `sources.xml` | `/sources` |
|
||||
| `now_playing.xml` | `/now_playing` |
|
||||
| `volume.xml` | `/volume` |
|
||||
| `bass.xml` | `/bass` |
|
||||
| `balance.xml` | `/balance` |
|
||||
| `capabilities.xml` | `/capabilities` |
|
||||
| `network_info.xml` | `/networkInfo` |
|
||||
| `clock_display.xml` | `/clockDisplay` |
|
||||
| `zone.xml` | `/getZone` |
|
||||
|
||||
Endpoints that return HTTP 4xx (not supported on the device model) are silently skipped.
|
||||
|
||||
**What gets fetched via SSH** (`--ssh`)
|
||||
|
||||
SSH connects as `root@<host>:22` with an empty password, which is the default for SoundTouch firmware.
|
||||
|
||||
Individual files:
|
||||
|
||||
| Remote path | Notes |
|
||||
|---------------------------|--------------------------------------------|
|
||||
| `/etc/hosts` | DNS redirect state |
|
||||
| `/etc/resolv.conf` | DNS resolver configuration |
|
||||
| `/etc/remote_services` | Service registration (post-migration only) |
|
||||
| `/mnt/nv/remote_services` | Alternative location for remote services |
|
||||
|
||||
Directories (all regular files recursively):
|
||||
|
||||
| Remote path | Contents |
|
||||
|----------------------------------|----------------------------------------------------------------------------|
|
||||
| `/opt/Bose/etc/` | Full Bose configuration directory, including `SoundTouchSdkPrivateCfg.xml` |
|
||||
| `/mnt/nv/BoseApp-Persistence/1/` | Persisted app state |
|
||||
|
||||
Missing files and directories are silently skipped with a `⚠` warning.
|
||||
|
||||
---
|
||||
|
||||
## Archive structure
|
||||
|
||||
Both subcommands write into a single dated archive:
|
||||
|
||||
```
|
||||
soundtouch-backup-2026-05-02/
|
||||
├── cloud/
|
||||
│ ├── emailaddress.xml
|
||||
│ ├── devices.xml
|
||||
│ ├── sources.xml
|
||||
│ └── presets.xml
|
||||
└── local/
|
||||
├── A_Sound_Machine/
|
||||
│ ├── info.xml
|
||||
│ ├── presets.xml
|
||||
│ ├── sources.xml
|
||||
│ ├── volume.xml
|
||||
│ ├── …
|
||||
│ └── ssh/
|
||||
│ ├── etc/
|
||||
│ │ ├── hosts
|
||||
│ │ └── resolv.conf
|
||||
│ ├── opt/Bose/etc/
|
||||
│ │ └── SoundTouchSdkPrivateCfg.xml
|
||||
│ └── mnt/nv/BoseApp-Persistence/1/
|
||||
└── Sound_Machinechen/
|
||||
└── …
|
||||
```
|
||||
|
||||
Running `cloud` and `local` separately produces two archives. To combine them, use the same `--output` path for both invocations — each adds its own subdirectory so they won't collide (`.tar.gz` does not support appending; use `--format zip` if you need a single archive from two runs, or just keep them separate).
|
||||
|
||||
## See also
|
||||
|
||||
- [Cloud Shutdown Survival Guide](../../docs/guides/SURVIVAL-GUIDE.md) — full migration context
|
||||
- [`soundtouch-cli`](../soundtouch-cli/) — live device control
|
||||
- [`soundtouch-service`](../soundtouch-service/) — local cloud replacement
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func allCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "all",
|
||||
Usage: "Back up cloud account then all paired speakers in one go",
|
||||
Description: "Authenticates with the Bose cloud, backs up account data, then reads" +
|
||||
" the device IP addresses from the cloud device list and backs up each reachable" +
|
||||
" speaker over HTTP (and optionally SSH).",
|
||||
Flags: append(outputFlags,
|
||||
&cli.StringFlag{
|
||||
Name: "email",
|
||||
Aliases: []string{"e"},
|
||||
Usage: "Bose account email",
|
||||
EnvVars: []string{"BOSE_EMAIL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"pw"},
|
||||
Usage: "Bose account password",
|
||||
EnvVars: []string{"BOSE_PASSWORD"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ssh",
|
||||
Usage: "Also back up device filesystem files via SSH (root@host:22, no password required)",
|
||||
Value: true,
|
||||
},
|
||||
),
|
||||
Action: runAllBackup,
|
||||
}
|
||||
}
|
||||
|
||||
func runAllBackup(c *cli.Context) error {
|
||||
doSSH := c.Bool("ssh")
|
||||
output := resolveOutputPath(c.String("output"), c.String("format"))
|
||||
format := c.String("format")
|
||||
|
||||
// 1. Cloud backup
|
||||
client, err := setupCloudClient(c.String("email"), c.String("password"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root := archiveRoot()
|
||||
files := collectCloudFiles(client, root)
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no cloud data fetched")
|
||||
}
|
||||
|
||||
// 2. Resolve speakers from devices.xml, then back each one up
|
||||
devicesData := files[root+"/cloud/devices.xml"]
|
||||
if devicesData == nil {
|
||||
printWarn("devices.xml not available — skipping local backup")
|
||||
} else {
|
||||
targets := parseDevicesXML(devicesData)
|
||||
if len(targets) == 0 {
|
||||
printWarn("no device IP addresses found in devices.xml")
|
||||
} else {
|
||||
fmt.Printf("Found %d device(s) in cloud account, attempting local backup...\n", len(targets))
|
||||
}
|
||||
|
||||
hc := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
for k, v := range collectLocalFiles(hc, targets, root, doSSH) {
|
||||
files[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeArchive(output, format, files); err != nil {
|
||||
return fmt.Errorf("writing archive: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type xmlDevice struct {
|
||||
Name string `xml:"name"`
|
||||
IPAddress string `xml:"ipaddress"`
|
||||
}
|
||||
|
||||
type xmlDevices struct {
|
||||
XMLName xml.Name `xml:"devices"`
|
||||
Devices []xmlDevice `xml:"device"`
|
||||
}
|
||||
|
||||
// parseDevicesXML extracts speaker targets from a devices.xml cloud response.
|
||||
func parseDevicesXML(data []byte) []speakerTarget {
|
||||
var d xmlDevices
|
||||
|
||||
if err := xml.Unmarshal(data, &d); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var targets []speakerTarget
|
||||
|
||||
for _, dev := range d.Devices {
|
||||
if dev.IPAddress == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pass name as a hint for error messages; backupSpeakerHTTP re-fetches
|
||||
// from /info to get the current name and include info.xml in the archive.
|
||||
targets = append(targets, speakerTarget{host: dev.IPAddress, port: 8090, name: dev.Name})
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
streamingBase = "https://streaming.bose.com"
|
||||
streamingCT = "application/vnd.bose.streaming-v1.1+xml"
|
||||
stockholmVer = "27.0.13-4277+8963611.epdbuild.develop.hepdswbld04.2025-10-02T13:17:00"
|
||||
nativeFrameVer = "27.0.2 -3353+4ae7c78.epdbuild.HEAD.ssgbld02.2023-10-12T15:10Z"
|
||||
protocolVer = "67"
|
||||
appGUID = "b94dedd1-a61b-492b-b86b-2bc32c9261f4"
|
||||
appUserAgent = "Mozilla/5.0 (Linux; Android 13; Android SDK built for arm64 Build/TE1A.220922.034; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.61 Mobile Safari/537.36 Manufacturer/unknown DeviceModel/Android-SDK-built-for-arm64 SOUNDTOUCH_MOBILE_APP/" + appGUID
|
||||
)
|
||||
|
||||
func cloudCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "cloud",
|
||||
Usage: "Back up your Bose SoundTouch cloud account (devices, presets, sources)",
|
||||
Flags: append(outputFlags,
|
||||
&cli.StringFlag{
|
||||
Name: "email",
|
||||
Aliases: []string{"e"},
|
||||
Usage: "Bose account email",
|
||||
EnvVars: []string{"BOSE_EMAIL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "password",
|
||||
Aliases: []string{"pw"},
|
||||
Usage: "Bose account password",
|
||||
EnvVars: []string{"BOSE_PASSWORD"},
|
||||
},
|
||||
),
|
||||
Action: runCloudBackup,
|
||||
}
|
||||
}
|
||||
|
||||
func runCloudBackup(c *cli.Context) error {
|
||||
output := resolveOutputPath(c.String("output"), c.String("format"))
|
||||
format := c.String("format")
|
||||
|
||||
client, err := setupCloudClient(c.String("email"), c.String("password"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root := archiveRoot()
|
||||
files := collectCloudFiles(client, root)
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no data fetched")
|
||||
}
|
||||
|
||||
if err := writeArchive(output, format, files); err != nil {
|
||||
return fmt.Errorf("writing archive: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupCloudClient prompts for missing credentials, then authenticates with the Bose cloud.
|
||||
func setupCloudClient(email, password string) (*cloudClient, error) {
|
||||
if email == "" || password == "" {
|
||||
var err error
|
||||
|
||||
email, password, err = promptCredentials(email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("credentials: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" || password == "" {
|
||||
return nil, fmt.Errorf("email and password are required")
|
||||
}
|
||||
|
||||
fmt.Printf("Authenticating as %s...\n", email)
|
||||
|
||||
client, err := loginToCloud(email, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
|
||||
printOK(fmt.Sprintf("Authenticated (account ID: %s)", client.accountID))
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// collectCloudFiles fetches all cloud account data and returns a files map ready for
|
||||
// archiving. Keys are prefixed with root (e.g. "soundtouch-backup-2026-05-02/cloud/").
|
||||
func collectCloudFiles(client *cloudClient, root string) map[string][]byte {
|
||||
type cloudEndpoint struct {
|
||||
label string
|
||||
filename string
|
||||
fetch func(*cloudClient) ([]byte, error)
|
||||
}
|
||||
|
||||
endpoints := []cloudEndpoint{
|
||||
{"email address", "emailaddress.xml", fetchEmailAddress},
|
||||
{"devices", "devices.xml", fetchDevices},
|
||||
{"sources", "sources.xml", fetchSources},
|
||||
{"presets", "presets.xml", fetchPresets},
|
||||
{"full account", "full.xml", fetchFull},
|
||||
}
|
||||
|
||||
files := make(map[string][]byte)
|
||||
|
||||
for _, ep := range endpoints {
|
||||
data, err := ep.fetch(client)
|
||||
if err != nil {
|
||||
printFail(fmt.Sprintf("%s: %v", ep.label, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
files[root+"/cloud/"+ep.filename] = data
|
||||
printOK(fmt.Sprintf("%s (%d bytes)", ep.label, len(data)))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
type cloudClient struct {
|
||||
http *http.Client
|
||||
accountID string
|
||||
token string
|
||||
}
|
||||
|
||||
type loginXML struct {
|
||||
XMLName xml.Name `xml:"login"`
|
||||
Username string `xml:"username"`
|
||||
Password string `xml:"password"`
|
||||
}
|
||||
|
||||
var accountIDRe = regexp.MustCompile(`<account\s+id="([^"]+)"`)
|
||||
|
||||
func loginToCloud(email, password string) (*cloudClient, error) {
|
||||
loginBody, err := xml.Marshal(loginXML{Username: email, Password: password})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := []byte(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
body = append(body, loginBody...)
|
||||
|
||||
req, err := http.NewRequest("POST", streamingBase+"/streaming/account/login", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setStreamingHeaders(req, "")
|
||||
|
||||
hc := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
token := resp.Header.Get("credentials")
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("no credentials in response — check your email and password")
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m := accountIDRe.FindSubmatch(data)
|
||||
if len(m) < 2 {
|
||||
return nil, fmt.Errorf("could not extract account ID from login response")
|
||||
}
|
||||
|
||||
return &cloudClient{http: hc, accountID: string(m[1]), token: token}, nil
|
||||
}
|
||||
|
||||
func setStreamingHeaders(req *http.Request, token string) {
|
||||
req.Header.Set("content-type", streamingCT)
|
||||
req.Header.Set("accept", streamingCT)
|
||||
req.Header.Set("clienttype", "SOUNDTOUCH_MOBILE_APP")
|
||||
req.Header.Set("version_stockholmversion", stockholmVer)
|
||||
req.Header.Set("version_nativeframeversion", nativeFrameVer)
|
||||
req.Header.Set("version_protocolversion", protocolVer)
|
||||
req.Header.Set("user-agent", appUserAgent)
|
||||
req.Header.Set("guid", appGUID)
|
||||
req.Header.Set("x-requested-with", "com.bose.soundtouch")
|
||||
req.Header.Set("pragma", "no-cache")
|
||||
req.Header.Set("cache-control", "no-cache")
|
||||
|
||||
if token != "" {
|
||||
req.Header.Set("authorization", token)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cloudClient) get(path string) ([]byte, error) {
|
||||
url := fmt.Sprintf("%s%s?_=%d", streamingBase, path, time.Now().UnixMilli())
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setStreamingHeaders(req, c.token)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
}
|
||||
|
||||
func fetchEmailAddress(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/emailaddress")
|
||||
}
|
||||
|
||||
func fetchDevices(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/devices")
|
||||
}
|
||||
|
||||
func fetchSources(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/sources")
|
||||
}
|
||||
|
||||
func fetchPresets(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/presets/all")
|
||||
}
|
||||
|
||||
func fetchFull(c *cloudClient) ([]byte, error) {
|
||||
return c.get("/streaming/account/" + c.accountID + "/full")
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/ssh"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var localEndpoints = []struct {
|
||||
path string
|
||||
file string
|
||||
}{
|
||||
{"/info", "info.xml"},
|
||||
{"/name", "name.xml"},
|
||||
{"/presets", "presets.xml"},
|
||||
{"/sources", "sources.xml"},
|
||||
{"/now_playing", "now_playing.xml"},
|
||||
{"/volume", "volume.xml"},
|
||||
{"/bass", "bass.xml"},
|
||||
{"/balance", "balance.xml"},
|
||||
{"/capabilities", "capabilities.xml"},
|
||||
{"/networkInfo", "network_info.xml"},
|
||||
{"/clockDisplay", "clock_display.xml"},
|
||||
{"/getZone", "zone.xml"},
|
||||
}
|
||||
|
||||
// sshFiles lists individual device filesystem paths captured via SSH.
|
||||
// Paths that may not exist on all devices are silently skipped.
|
||||
var sshFiles = []string{
|
||||
"/etc/hosts",
|
||||
"/etc/resolv.conf",
|
||||
"/etc/remote_services",
|
||||
"/mnt/nv/remote_services",
|
||||
}
|
||||
|
||||
// sshDirs lists device directories whose contents are recursively captured via SSH.
|
||||
var sshDirs = []string{
|
||||
"/opt/Bose/etc",
|
||||
"/mnt/nv/BoseApp-Persistence/1",
|
||||
}
|
||||
|
||||
func localCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "local",
|
||||
Usage: "Back up one or more SoundTouch speakers on your local network",
|
||||
Flags: append(outputFlags,
|
||||
&cli.StringSliceFlag{
|
||||
Name: "host",
|
||||
Aliases: []string{"H"},
|
||||
Usage: "Speaker host/IP (repeatable for multiple speakers)",
|
||||
EnvVars: []string{"SOUNDTOUCH_HOST"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "Speaker HTTP port",
|
||||
Value: 8090,
|
||||
EnvVars: []string{"SOUNDTOUCH_PORT"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discover",
|
||||
Aliases: []string{"d"},
|
||||
Usage: "Auto-discover speakers on the local network",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "discover-timeout",
|
||||
Usage: "Discovery timeout",
|
||||
Value: 5 * time.Second,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "ssh",
|
||||
Usage: "Also back up device filesystem files via SSH (root@host:22, no password required)",
|
||||
Value: true,
|
||||
},
|
||||
),
|
||||
Action: runLocalBackup,
|
||||
}
|
||||
}
|
||||
|
||||
type speakerTarget struct {
|
||||
host string
|
||||
port int
|
||||
name string
|
||||
}
|
||||
|
||||
func runLocalBackup(c *cli.Context) error {
|
||||
hosts := c.StringSlice("host")
|
||||
port := c.Int("port")
|
||||
doDiscover := c.Bool("discover") || len(hosts) == 0
|
||||
discoverTimeout := c.Duration("discover-timeout")
|
||||
doSSH := c.Bool("ssh")
|
||||
output := resolveOutputPath(c.String("output"), c.String("format"))
|
||||
format := c.String("format")
|
||||
|
||||
var targets []speakerTarget
|
||||
|
||||
if doDiscover {
|
||||
fmt.Printf("Discovering speakers (timeout: %s)...\n", discoverTimeout)
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Context, discoverTimeout)
|
||||
defer cancel()
|
||||
|
||||
cfg, _ := config.LoadFromEnv()
|
||||
svc := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
found, discErr := svc.DiscoverDevices(ctx)
|
||||
if discErr != nil {
|
||||
printWarn(fmt.Sprintf("Discovery failed: %v", discErr))
|
||||
}
|
||||
|
||||
for _, d := range found {
|
||||
targets = append(targets, speakerTarget{host: d.Host, port: d.Port, name: d.Name})
|
||||
printOK(fmt.Sprintf("Found: %s (%s:%d)", d.Name, d.Host, d.Port))
|
||||
}
|
||||
}
|
||||
|
||||
for _, h := range hosts {
|
||||
targets = append(targets, speakerTarget{host: h, port: port})
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
return fmt.Errorf("no speakers found — use --host <ip> or --discover")
|
||||
}
|
||||
|
||||
hc := &http.Client{Timeout: 10 * time.Second}
|
||||
root := archiveRoot()
|
||||
files := collectLocalFiles(hc, targets, root, doSSH)
|
||||
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no data collected")
|
||||
}
|
||||
|
||||
if err := writeArchive(output, format, files); err != nil {
|
||||
return fmt.Errorf("writing archive: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Archive written: %s (%d files)\n", output, len(files))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectLocalFiles backs up all targets over HTTP (and optionally SSH) and returns
|
||||
// a files map ready for archiving. Keys are prefixed with root.
|
||||
func collectLocalFiles(hc *http.Client, targets []speakerTarget, root string, doSSH bool) map[string][]byte {
|
||||
files := make(map[string][]byte)
|
||||
|
||||
for _, t := range targets {
|
||||
name, entries, err := backupSpeakerHTTP(hc, t)
|
||||
if err != nil {
|
||||
printFail(fmt.Sprintf("%s:%d — %v", t.host, t.port, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
dir := root + "/local/" + sanitizeName(name) + "/"
|
||||
|
||||
for filename, data := range entries {
|
||||
files[dir+filename] = data
|
||||
}
|
||||
|
||||
printOK(fmt.Sprintf("%s: %d files via HTTP", name, len(entries)))
|
||||
|
||||
if doSSH {
|
||||
sshEntries := backupSpeakerSSH(t.host, name)
|
||||
|
||||
for filename, data := range sshEntries {
|
||||
files[dir+filename] = data
|
||||
}
|
||||
|
||||
if len(sshEntries) > 0 {
|
||||
printOK(fmt.Sprintf("%s: %d files via SSH", name, len(sshEntries)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
func backupSpeakerHTTP(hc *http.Client, t speakerTarget) (name string, files map[string][]byte, err error) {
|
||||
base := fmt.Sprintf("http://%s:%d", t.host, t.port)
|
||||
files = make(map[string][]byte)
|
||||
name = t.name
|
||||
infoFetched := false
|
||||
|
||||
if name == "" {
|
||||
data, ferr := fetchRaw(hc, base+"/info")
|
||||
if ferr != nil {
|
||||
return "", nil, fmt.Errorf("cannot reach %s: %w", base, ferr)
|
||||
}
|
||||
|
||||
files["info.xml"] = data
|
||||
infoFetched = true
|
||||
|
||||
if extracted := xmlFirst(data, "name"); extracted != "" {
|
||||
name = extracted
|
||||
} else {
|
||||
name = t.host
|
||||
}
|
||||
}
|
||||
|
||||
for _, ep := range localEndpoints {
|
||||
if ep.path == "/info" && infoFetched {
|
||||
continue
|
||||
}
|
||||
|
||||
data, ferr := fetchRaw(hc, base+ep.path)
|
||||
if ferr != nil {
|
||||
printWarn(fmt.Sprintf("%s: skipped %s (%v)", name, ep.file, ferr))
|
||||
continue
|
||||
}
|
||||
|
||||
files[ep.file] = data
|
||||
}
|
||||
|
||||
return name, files, nil
|
||||
}
|
||||
|
||||
// backupSpeakerSSH connects to the device via SSH and reads the key filesystem paths.
|
||||
// Files that don't exist on the device are silently skipped.
|
||||
// Returned map keys are relative paths within the device backup directory (e.g. "ssh/etc/hosts").
|
||||
func backupSpeakerSSH(host, deviceName string) map[string][]byte {
|
||||
client := ssh.NewClient(host)
|
||||
files := make(map[string][]byte)
|
||||
|
||||
for _, remotePath := range sshFiles {
|
||||
data, err := client.ReadFile(remotePath)
|
||||
if err != nil {
|
||||
// Most missing files are expected (e.g. /etc/remote_services only exists post-migration)
|
||||
printWarn(fmt.Sprintf("%s: SSH skipped %s (%v)", deviceName, remotePath, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
printWarn(fmt.Sprintf("%s: SSH empty file %s", deviceName, remotePath))
|
||||
}
|
||||
|
||||
files["ssh"+remotePath] = data
|
||||
}
|
||||
|
||||
for _, remoteDir := range sshDirs {
|
||||
dirFiles, err := client.ReadDir(remoteDir)
|
||||
if err != nil {
|
||||
printWarn(fmt.Sprintf("%s: SSH skipped dir %s (%v)", deviceName, remoteDir, err))
|
||||
continue
|
||||
}
|
||||
|
||||
for path, data := range dirFiles {
|
||||
files["ssh"+path] = data
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
func fetchRaw(hc *http.Client, url string) ([]byte, error) {
|
||||
resp, err := hc.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
}
|
||||
|
||||
func xmlFirst(data []byte, field string) string {
|
||||
re := regexp.MustCompile(`<` + regexp.QuoteMeta(field) + `[^>]*>([^<]+)</` + regexp.QuoteMeta(field) + `>`)
|
||||
|
||||
m := re.FindSubmatch(data)
|
||||
if len(m) >= 2 {
|
||||
return strings.TrimSpace(string(m[1]))
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
FormatTarGz = "tar.gz"
|
||||
FormatZip = "zip"
|
||||
)
|
||||
|
||||
var outputFlags = []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "output",
|
||||
Aliases: []string{"o"},
|
||||
Usage: "Output archive file (default: soundtouch-backup-YYYY-MM-DD.tar.gz)",
|
||||
EnvVars: []string{"SOUNDTOUCH_BACKUP_OUTPUT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "format",
|
||||
Usage: "Archive format: tar.gz or zip",
|
||||
Value: FormatTarGz,
|
||||
},
|
||||
}
|
||||
|
||||
func resolveOutputPath(output, format string) string {
|
||||
date := time.Now().Format("2006-01-02")
|
||||
|
||||
ext := ".tar.gz"
|
||||
if format == FormatZip {
|
||||
ext = ".zip"
|
||||
}
|
||||
|
||||
filename := "soundtouch-backup-" + date + ext
|
||||
|
||||
if output == "" {
|
||||
return filename
|
||||
}
|
||||
|
||||
if info, err := os.Stat(output); err == nil && info.IsDir() {
|
||||
return output + string(os.PathSeparator) + filename
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func archiveRoot() string {
|
||||
return "soundtouch-backup-" + time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func writeArchive(outputPath, format string, files map[string][]byte) error {
|
||||
if format == FormatZip {
|
||||
return writeZip(outputPath, files)
|
||||
}
|
||||
|
||||
return writeTarGz(outputPath, files)
|
||||
}
|
||||
|
||||
func writeTarGz(outputPath string, files map[string][]byte) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gz := gzip.NewWriter(f)
|
||||
defer gz.Close()
|
||||
|
||||
tw := tar.NewWriter(gz)
|
||||
defer tw.Close()
|
||||
|
||||
now := time.Now()
|
||||
for name, data := range files {
|
||||
hdr := &tar.Header{
|
||||
Name: name,
|
||||
Mode: 0644,
|
||||
Size: int64(len(data)),
|
||||
ModTime: now,
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return fmt.Errorf("tar header %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := tw.Write(data); err != nil {
|
||||
return fmt.Errorf("tar write %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeZip(outputPath string, files map[string][]byte) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
zw := zip.NewWriter(f)
|
||||
defer zw.Close()
|
||||
|
||||
for name, data := range files {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("zip entry %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return fmt.Errorf("zip write %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptCredentials(emailHint string) (email, password string, err error) {
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
|
||||
if emailHint != "" {
|
||||
email = emailHint
|
||||
} else {
|
||||
fmt.Print("Bose account email: ")
|
||||
|
||||
email, err = r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
email = strings.TrimSpace(email)
|
||||
}
|
||||
|
||||
fmt.Print("Password: ")
|
||||
|
||||
raw, termErr := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
|
||||
fmt.Println()
|
||||
|
||||
if termErr != nil {
|
||||
err = fmt.Errorf("reading password: %w (tip: use --password flag or BOSE_PASSWORD env var)", termErr)
|
||||
return
|
||||
}
|
||||
|
||||
password = string(raw)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func sanitizeName(name string) string {
|
||||
r := strings.NewReplacer(
|
||||
"/", "_", "\\", "_", ":", "_",
|
||||
"*", "_", "?", "_", "\"", "_",
|
||||
"<", "_", ">", "_", "|", "_",
|
||||
" ", "_",
|
||||
)
|
||||
|
||||
return r.Replace(name)
|
||||
}
|
||||
|
||||
func printOK(msg string) { fmt.Printf(" ✓ %s\n", msg) }
|
||||
func printFail(msg string) { fmt.Printf(" ✗ %s\n", msg) }
|
||||
func printWarn(msg string) { fmt.Printf(" ⚠ %s\n", msg) }
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package main implements the soundtouch-backup tool for backing up Bose SoundTouch
|
||||
// cloud account data and local speaker filesystem files.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func init() {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
if info.Main.Version != "" && info.Main.Version != "(devel)" {
|
||||
version = info.Main.Version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-backup",
|
||||
Usage: "Back up Bose SoundTouch account and speaker data",
|
||||
Version: version,
|
||||
Commands: []*cli.Command{
|
||||
allCommand(),
|
||||
cloudCommand(),
|
||||
localCommand(),
|
||||
},
|
||||
}
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// createCredentialsForSource creates credentials for the specified source type
|
||||
func createCredentialsForSource(source, user, password, displayName string) *models.MusicServiceCredentials {
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
return models.NewSpotifyCredentials(user, password)
|
||||
case "PANDORA":
|
||||
return models.NewPandoraCredentials(user, password)
|
||||
case "AMAZON":
|
||||
return models.NewAmazonMusicCredentials(user, password)
|
||||
case "DEEZER":
|
||||
return models.NewDeezerCredentials(user, password)
|
||||
case "IHEART":
|
||||
return models.NewIHeartRadioCredentials(user, password)
|
||||
case "STORED_MUSIC":
|
||||
if displayName == "" {
|
||||
displayName = "Network Music Library"
|
||||
}
|
||||
|
||||
return models.NewStoredMusicCredentials(user, displayName)
|
||||
default:
|
||||
// Generic credentials for other services
|
||||
if displayName == "" {
|
||||
displayName = source
|
||||
}
|
||||
|
||||
return models.NewMusicServiceCredentials(source, displayName, user, password)
|
||||
}
|
||||
}
|
||||
|
||||
// validateAccountInput validates the input parameters for account management
|
||||
func validateAccountInput(source, user, password string) error {
|
||||
if source == "" {
|
||||
return fmt.Errorf("source is required (use --source)")
|
||||
}
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
// STORED_MUSIC doesn't require a password
|
||||
if source != "STORED_MUSIC" && password == "" {
|
||||
return fmt.Errorf("password is required for %s (use --password)", source)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addMusicServiceAccount handles adding a music service account
|
||||
func addMusicServiceAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source := strings.ToUpper(c.String("source"))
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
displayName := c.String("name")
|
||||
|
||||
if validationErr := validateAccountInput(source, user, password); validationErr != nil {
|
||||
return validationErr
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Adding %s account", source), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
credentials := createCredentialsForSource(source, user, password, displayName)
|
||||
|
||||
// Override display name if provided
|
||||
if c.IsSet("name") {
|
||||
credentials.DisplayName = displayName
|
||||
}
|
||||
|
||||
fmt.Printf(" Service: %s\n", credentials.GetDescription())
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
if source == "STORED_MUSIC" {
|
||||
fmt.Printf(" Type: Network Music Library\n")
|
||||
} else {
|
||||
fmt.Printf(" Type: Streaming Service\n")
|
||||
}
|
||||
|
||||
err = client.SetMusicServiceAccount(credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add music service account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("%s account added successfully", source))
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select this source: soundtouch-cli --host %s source select --source %s --account %s\n", clientConfig.Host, source, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeMusicServiceAccount handles removing a music service account
|
||||
func removeMusicServiceAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source := strings.ToUpper(c.String("source"))
|
||||
user := c.String("user")
|
||||
displayName := c.String("name")
|
||||
|
||||
if source == "" {
|
||||
return fmt.Errorf("source is required (use --source)")
|
||||
}
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Removing %s account", source), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
var credentials *models.MusicServiceCredentials
|
||||
|
||||
// Create credentials for removal (empty password)
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
credentials = models.NewSpotifyCredentials(user, "")
|
||||
case "PANDORA":
|
||||
credentials = models.NewPandoraCredentials(user, "")
|
||||
case "AMAZON":
|
||||
credentials = models.NewAmazonMusicCredentials(user, "")
|
||||
case "DEEZER":
|
||||
credentials = models.NewDeezerCredentials(user, "")
|
||||
case "IHEART":
|
||||
credentials = models.NewIHeartRadioCredentials(user, "")
|
||||
case "STORED_MUSIC":
|
||||
if displayName == "" {
|
||||
displayName = "Network Music Library"
|
||||
}
|
||||
|
||||
credentials = models.NewStoredMusicCredentials(user, displayName)
|
||||
default:
|
||||
// Generic credentials for other services
|
||||
if displayName == "" {
|
||||
displayName = source
|
||||
}
|
||||
|
||||
credentials = models.NewMusicServiceCredentials(source, displayName, user, "")
|
||||
}
|
||||
|
||||
// Override display name if provided
|
||||
if c.IsSet("name") {
|
||||
credentials.DisplayName = displayName
|
||||
}
|
||||
|
||||
fmt.Printf(" Service: %s\n", credentials.GetDescription())
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemoveMusicServiceAccount(credentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove music service account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("%s account removed successfully", source))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addSpotifyAccount is a convenience command for adding Spotify accounts
|
||||
func addSpotifyAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required (use --password)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding Spotify Premium account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
fmt.Printf(" Service: Spotify Premium\n")
|
||||
|
||||
err = client.AddSpotifyAccount(user, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add Spotify account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Spotify account added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select Spotify: soundtouch-cli --host %s source spotify\n", clientConfig.Host)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeSpotifyAccount is a convenience command for removing Spotify accounts
|
||||
func removeSpotifyAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing Spotify account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemoveSpotifyAccount(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove Spotify account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Spotify account removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPandoraAccount is a convenience command for adding Pandora accounts
|
||||
func addPandoraAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required (use --password)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding Pandora account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
fmt.Printf(" Service: Pandora Music Service\n")
|
||||
|
||||
err = client.AddPandoraAccount(user, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add Pandora account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Pandora account added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select Pandora: soundtouch-cli --host %s source select --source PANDORA --account %s\n", clientConfig.Host, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removePandoraAccount is a convenience command for removing Pandora accounts
|
||||
func removePandoraAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing Pandora account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemovePandoraAccount(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove Pandora account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Pandora account removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addStoredMusicAccount is a convenience command for adding STORED_MUSIC accounts
|
||||
func addStoredMusicAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
displayName := c.String("name")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user) - this should be the UPnP server GUID with /0 suffix")
|
||||
}
|
||||
|
||||
if displayName == "" {
|
||||
displayName = "Network Music Library"
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding network music library", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" Server ID: %s\n", user)
|
||||
fmt.Printf(" Display Name: %s\n", displayName)
|
||||
fmt.Printf(" Type: UPnP/DLNA Media Server\n")
|
||||
|
||||
err = client.AddStoredMusicAccount(user, displayName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add network music library: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Network music library added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Browse library: soundtouch-cli --host %s browse stored-music --account %s\n", clientConfig.Host, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addAmazonMusicAccount is a convenience command for adding Amazon Music accounts
|
||||
func addAmazonMusicAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required (use --password)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding Amazon Music account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
fmt.Printf(" Service: Amazon Music\n")
|
||||
|
||||
err = client.AddAmazonMusicAccount(user, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add Amazon Music account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Amazon Music account added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select Amazon Music: soundtouch-cli --host %s source select --source AMAZON --account %s\n", clientConfig.Host, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeAmazonMusicAccount is a convenience command for removing Amazon Music accounts
|
||||
func removeAmazonMusicAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing Amazon Music account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemoveAmazonMusicAccount(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove Amazon Music account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Amazon Music account removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addDeezerAccount is a convenience command for adding Deezer accounts
|
||||
func addDeezerAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required (use --password)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding Deezer Premium account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
fmt.Printf(" Service: Deezer Premium\n")
|
||||
|
||||
err = client.AddDeezerAccount(user, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add Deezer account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Deezer account added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select Deezer: soundtouch-cli --host %s source select --source DEEZER --account %s\n", clientConfig.Host, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeDeezerAccount is a convenience command for removing Deezer accounts
|
||||
func removeDeezerAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing Deezer account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemoveDeezerAccount(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove Deezer account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Deezer account removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addIHeartRadioAccount is a convenience command for adding iHeartRadio accounts
|
||||
func addIHeartRadioAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
password := c.String("password")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required (use --password)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Adding iHeartRadio account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
fmt.Printf(" Service: iHeartRadio\n")
|
||||
|
||||
err = client.AddIHeartRadioAccount(user, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add iHeartRadio account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("iHeartRadio account added successfully")
|
||||
|
||||
// Show next steps
|
||||
fmt.Printf("\n💡 Next Steps:\n")
|
||||
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
|
||||
fmt.Printf(" • Select iHeartRadio: soundtouch-cli --host %s source select --source IHEART --account %s\n", clientConfig.Host, user)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeIHeartRadioAccount is a convenience command for removing iHeartRadio accounts
|
||||
func removeIHeartRadioAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing iHeartRadio account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" User: %s\n", user)
|
||||
|
||||
err = client.RemoveIHeartRadioAccount(user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove iHeartRadio account: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("iHeartRadio account removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeStoredMusicAccount is a convenience command for removing STORED_MUSIC accounts
|
||||
func removeStoredMusicAccount(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user := c.String("user")
|
||||
displayName := c.String("name")
|
||||
|
||||
if user == "" {
|
||||
return fmt.Errorf("user is required (use --user)")
|
||||
}
|
||||
|
||||
if displayName == "" {
|
||||
displayName = "Network Music Library"
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Removing network music library", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" Server ID: %s\n", user)
|
||||
fmt.Printf(" Display Name: %s\n", displayName)
|
||||
|
||||
err = client.RemoveStoredMusicAccount(user, displayName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove network music library: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Network music library removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// listMusicServiceAccounts shows configured music service accounts from sources
|
||||
func listMusicServiceAccounts(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Music service accounts", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
sources, err := client.GetSources()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sources: %w", err)
|
||||
}
|
||||
|
||||
// Filter for streaming/music service sources
|
||||
musicSources := []string{"SPOTIFY", "PANDORA", "AMAZON", "DEEZER", "IHEART", "STORED_MUSIC", "LOCAL_MUSIC"}
|
||||
|
||||
found := false
|
||||
|
||||
for _, musicSource := range musicSources {
|
||||
sourcesOfType := sources.GetSourcesByType(musicSource)
|
||||
if len(sourcesOfType) > 0 {
|
||||
found = true
|
||||
|
||||
fmt.Printf("\n📱 %s:\n", getServiceDisplayName(musicSource))
|
||||
|
||||
for _, source := range sourcesOfType {
|
||||
status := "🔴 Unavailable"
|
||||
if source.Status == models.SourceStatusReady {
|
||||
status = "🟢 Ready"
|
||||
}
|
||||
|
||||
accountInfo := ""
|
||||
if source.SourceAccount != "" && source.SourceAccount != source.Source {
|
||||
accountInfo = fmt.Sprintf(" (%s)", source.SourceAccount)
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s%s\n", status, source.GetDisplayName(), accountInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
fmt.Printf(" 📭 No music service accounts configured\n")
|
||||
fmt.Printf("\n💡 Add accounts with:\n")
|
||||
fmt.Printf(" • soundtouch-cli --host %s account add-spotify --user <email> --password <pass>\n", clientConfig.Host)
|
||||
fmt.Printf(" • soundtouch-cli --host %s account add-pandora --user <user> --password <pass>\n", clientConfig.Host)
|
||||
fmt.Printf(" • soundtouch-cli --host %s account add --source AMAZON --user <user> --password <pass>\n", clientConfig.Host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pairDevice triggers the Stockholm registration flow via WebSocket
|
||||
func pairDevice(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountID := c.String("id")
|
||||
token := c.String("token")
|
||||
|
||||
PrintDeviceHeader("Pairing device with Marge account", clientConfig.Host, clientConfig.Port)
|
||||
fmt.Printf(" Account ID: %s\n", accountID)
|
||||
|
||||
// We need a WebSocket client for this
|
||||
ws := client.NewWebSocketClient(nil)
|
||||
|
||||
err = ws.Connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = ws.Disconnect() }()
|
||||
|
||||
err = ws.PairWithAccount(accountID, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send pairing request: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Pairing request sent successfully")
|
||||
fmt.Println("💡 The device will now register itself with the cloud service.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unpairDevice triggers the Stockholm unregistration flow via WebSocket
|
||||
func unpairDevice(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Unpairing device from Marge account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
// We need a WebSocket client for this
|
||||
ws := client.NewWebSocketClient(nil)
|
||||
|
||||
err = ws.Connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = ws.Disconnect() }()
|
||||
|
||||
err = ws.UnPairFromAccount()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send unpairing request: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Unpairing request sent successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getServiceDisplayName returns a user-friendly display name for a service
|
||||
func getServiceDisplayName(source string) string {
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
return "Spotify"
|
||||
case "PANDORA":
|
||||
return "Pandora"
|
||||
case "AMAZON":
|
||||
return "Amazon Music"
|
||||
case "DEEZER":
|
||||
return "Deezer"
|
||||
case "IHEART":
|
||||
return "iHeartRadio"
|
||||
case "STORED_MUSIC":
|
||||
return "Network Libraries"
|
||||
case "LOCAL_MUSIC":
|
||||
return "Local Music Servers"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// getAudioDSPControls gets the current DSP audio controls
|
||||
func getAudioDSPControls(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Getting DSP audio controls", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
dspControls, err := client.GetAudioDSPControls()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get DSP controls: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("DSP Audio Controls:")
|
||||
fmt.Printf(" Audio Mode: %s\n", dspControls.AudioMode)
|
||||
fmt.Printf(" Video Sync Audio Delay: %d ms\n", dspControls.VideoSyncAudioDelay)
|
||||
|
||||
supportedModes := dspControls.GetSupportedAudioModes()
|
||||
if len(supportedModes) > 0 {
|
||||
fmt.Printf(" Supported Audio Modes: %s\n", strings.Join(supportedModes, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAudioDSPControls sets the DSP audio controls
|
||||
func setAudioDSPControls(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
audioMode := c.String("mode")
|
||||
videoSyncDelay := c.Int("delay")
|
||||
|
||||
if audioMode == "" && videoSyncDelay == 0 {
|
||||
return fmt.Errorf("at least one of --mode or --delay must be specified")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Setting DSP audio controls", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SetAudioDSPControls(audioMode, videoSyncDelay)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set DSP controls: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("✅ DSP controls updated successfully")
|
||||
|
||||
if audioMode != "" {
|
||||
fmt.Printf(" Audio Mode: %s\n", audioMode)
|
||||
}
|
||||
|
||||
if videoSyncDelay != 0 {
|
||||
fmt.Printf(" Video Sync Delay: %d ms\n", videoSyncDelay)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAudioMode sets only the audio mode
|
||||
func setAudioMode(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
audioMode := c.String("mode")
|
||||
|
||||
if audioMode == "" {
|
||||
return fmt.Errorf("audio mode is required (use --mode)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Setting audio mode to '%s'", audioMode), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SetAudioMode(audioMode)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set audio mode: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Audio mode set to '%s'\n", audioMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setVideoSyncDelay sets only the video sync audio delay
|
||||
func setVideoSyncDelay(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
delay := c.Int("delay")
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Setting video sync audio delay to %d ms", delay), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SetVideoSyncAudioDelay(delay)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set video sync delay: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Video sync audio delay set to %d ms\n", delay)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAudioToneControls gets the current advanced tone controls
|
||||
func getAudioToneControls(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Getting advanced tone controls", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
toneControls, err := client.GetAudioProductToneControls()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get tone controls: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Advanced Tone Controls:")
|
||||
fmt.Printf(" Bass: %d (range: %d to %d, step: %d)\n",
|
||||
toneControls.Bass.Value, toneControls.Bass.MinValue, toneControls.Bass.MaxValue, toneControls.Bass.Step)
|
||||
fmt.Printf(" Treble: %d (range: %d to %d, step: %d)\n",
|
||||
toneControls.Treble.Value, toneControls.Treble.MinValue, toneControls.Treble.MaxValue, toneControls.Treble.Step)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAudioToneControls sets the advanced tone controls
|
||||
func setAudioToneControls(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
bassStr := c.String("bass")
|
||||
trebleStr := c.String("treble")
|
||||
|
||||
if bassStr == "" && trebleStr == "" {
|
||||
return fmt.Errorf("at least one of --bass or --treble must be specified")
|
||||
}
|
||||
|
||||
var bass, treble *int
|
||||
|
||||
var err error
|
||||
|
||||
if bassStr != "" {
|
||||
bassVal, errVal := strconv.Atoi(bassStr)
|
||||
if errVal != nil {
|
||||
return fmt.Errorf("invalid bass value: %s", bassStr)
|
||||
}
|
||||
|
||||
bass = &bassVal
|
||||
}
|
||||
|
||||
if trebleStr != "" {
|
||||
trebleVal, errVal := strconv.Atoi(trebleStr)
|
||||
if errVal != nil {
|
||||
return fmt.Errorf("invalid treble value: %s", trebleStr)
|
||||
}
|
||||
|
||||
treble = &trebleVal
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Setting advanced tone controls", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SetAudioProductToneControls(bass, treble)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set tone controls: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("✅ Advanced tone controls updated successfully")
|
||||
|
||||
if bass != nil {
|
||||
fmt.Printf(" Bass: %d\n", *bass)
|
||||
}
|
||||
|
||||
if treble != nil {
|
||||
fmt.Printf(" Treble: %d\n", *treble)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAdvancedBass sets only the advanced bass control
|
||||
func setAdvancedBass(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
level := c.Int("level")
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Setting advanced bass to %d", level), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SetAdvancedBass(level)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set advanced bass: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Advanced bass set to %d\n", level)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAdvancedTreble sets only the advanced treble control
|
||||
func setAdvancedTreble(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
level := c.Int("level")
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Setting advanced treble to %d", level), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SetAdvancedTreble(level)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set advanced treble: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Advanced treble set to %d\n", level)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAudioLevelControls gets the current speaker level controls
|
||||
func getAudioLevelControls(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Getting speaker level controls", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
levelControls, err := client.GetAudioProductLevelControls()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get level controls: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Speaker Level Controls:")
|
||||
fmt.Printf(" Front-Center Speaker: %d (range: %d to %d, step: %d)\n",
|
||||
levelControls.FrontCenterSpeakerLevel.Value,
|
||||
levelControls.FrontCenterSpeakerLevel.MinValue,
|
||||
levelControls.FrontCenterSpeakerLevel.MaxValue,
|
||||
levelControls.FrontCenterSpeakerLevel.Step)
|
||||
fmt.Printf(" Rear-Surround Speakers: %d (range: %d to %d, step: %d)\n",
|
||||
levelControls.RearSurroundSpeakersLevel.Value,
|
||||
levelControls.RearSurroundSpeakersLevel.MinValue,
|
||||
levelControls.RearSurroundSpeakersLevel.MaxValue,
|
||||
levelControls.RearSurroundSpeakersLevel.Step)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setAudioLevelControls sets the speaker level controls
|
||||
func setAudioLevelControls(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
frontCenterStr := c.String("front-center")
|
||||
rearSurroundStr := c.String("rear-surround")
|
||||
|
||||
if frontCenterStr == "" && rearSurroundStr == "" {
|
||||
return fmt.Errorf("at least one of --front-center or --rear-surround must be specified")
|
||||
}
|
||||
|
||||
var frontCenter, rearSurround *int
|
||||
|
||||
var err error
|
||||
|
||||
if frontCenterStr != "" {
|
||||
frontCenterVal, errVal := strconv.Atoi(frontCenterStr)
|
||||
if errVal != nil {
|
||||
return fmt.Errorf("invalid front-center value: %s", frontCenterStr)
|
||||
}
|
||||
|
||||
frontCenter = &frontCenterVal
|
||||
}
|
||||
|
||||
if rearSurroundStr != "" {
|
||||
rearSurroundVal, errVal := strconv.Atoi(rearSurroundStr)
|
||||
if errVal != nil {
|
||||
return fmt.Errorf("invalid rear-surround value: %s", rearSurroundStr)
|
||||
}
|
||||
|
||||
rearSurround = &rearSurroundVal
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Setting speaker level controls", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SetAudioProductLevelControls(frontCenter, rearSurround)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set level controls: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("✅ Speaker level controls updated successfully")
|
||||
|
||||
if frontCenter != nil {
|
||||
fmt.Printf(" Front-Center Speaker: %d\n", *frontCenter)
|
||||
}
|
||||
|
||||
if rearSurround != nil {
|
||||
fmt.Printf(" Rear-Surround Speakers: %d\n", *rearSurround)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setFrontCenterLevel sets only the front-center speaker level
|
||||
func setFrontCenterLevel(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
level := c.Int("level")
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Setting front-center speaker level to %d", level), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SetFrontCenterSpeakerLevel(level)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set front-center speaker level: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Front-center speaker level set to %d\n", level)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setRearSurroundLevel sets only the rear-surround speakers level
|
||||
func setRearSurroundLevel(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
level := c.Int("level")
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Setting rear-surround speakers level to %d", level), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SetRearSurroundSpeakersLevel(level)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set rear-surround speakers level: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Rear-surround speakers level set to %d\n", level)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -27,20 +27,51 @@ func getClockTime(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Clock Time Information:")
|
||||
|
||||
if timeObj, err := clockTime.GetTime(); err == nil {
|
||||
fmt.Printf("Current time: %02d:%02d\n", timeObj.Hour(), timeObj.Minute())
|
||||
fmt.Printf("UTC time: %s\n", timeObj.Format("2006-01-02 15:04:05 MST"))
|
||||
fmt.Printf(" Current time: %s\n", timeObj.Format("2006-01-02 15:04:05"))
|
||||
fmt.Printf(" Local time: %02d:%02d:%02d\n", timeObj.Hour(), timeObj.Minute(), timeObj.Second())
|
||||
} else {
|
||||
fmt.Printf("Time value: %s\n", clockTime.Value)
|
||||
fmt.Printf(" Parse error: %v\n", err)
|
||||
|
||||
if clockTime.Value != "" {
|
||||
fmt.Printf(" Raw value: %s\n", clockTime.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if clockTime.GetLocalTime() != nil {
|
||||
lt := clockTime.GetLocalTime()
|
||||
|
||||
fmt.Printf(" Local time details:\n")
|
||||
fmt.Printf(" Date: %04d-%02d-%02d (day %d)\n", lt.Year, lt.Month+1, lt.DayOfMonth, lt.DayOfWeek)
|
||||
fmt.Printf(" Time: %02d:%02d:%02d\n", lt.Hour, lt.Minute, lt.Second)
|
||||
}
|
||||
|
||||
if clockTime.GetUTC() > 0 {
|
||||
utcTime := time.Unix(clockTime.GetUTC(), 0)
|
||||
fmt.Printf("UTC timestamp: %d (%s)\n", clockTime.GetUTC(), utcTime.Format("2006-01-02 15:04:05 MST"))
|
||||
fmt.Printf(" UTC timestamp: %d (%s)\n", clockTime.GetUTC(), utcTime.Format("2006-01-02 15:04:05 MST"))
|
||||
}
|
||||
|
||||
if clockTime.GetTimeFormat() != "" {
|
||||
fmt.Printf(" Time format: %s\n", clockTime.GetTimeFormat())
|
||||
}
|
||||
|
||||
if clockTime.GetBrightness() > 0 {
|
||||
fmt.Printf(" Brightness: %d\n", clockTime.GetBrightness())
|
||||
}
|
||||
|
||||
if clockTime.GetUTCSyncTime() > 0 {
|
||||
syncTime := time.Unix(clockTime.GetUTCSyncTime(), 0)
|
||||
fmt.Printf(" Last sync: %s\n", syncTime.Format("2006-01-02 15:04:05 MST"))
|
||||
}
|
||||
|
||||
if clockTime.GetClockError() != 0 {
|
||||
fmt.Printf(" Clock error: %d\n", clockTime.GetClockError())
|
||||
}
|
||||
|
||||
if clockTime.GetZone() != "" {
|
||||
fmt.Printf("Time zone: %s\n", clockTime.GetZone())
|
||||
fmt.Printf(" Time zone: %s\n", clockTime.GetZone())
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -126,6 +157,33 @@ func setClockTimeNow(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// setClockDisplayTimezone POSTs only the timezoneInfo attribute,
|
||||
// leaving format/brightness untouched. Useful after a clock now to
|
||||
// make the speaker's logs and front-panel display tick in local time
|
||||
// instead of UTC.
|
||||
func setClockDisplayTimezone(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
tz := c.String("tz")
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Setting clock timezone to %s", tz), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
request := models.NewClockDisplayRequest().SetTimeZone(tz)
|
||||
if err := client.SetClockDisplay(request); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to set timezone: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Timezone set to %s", tz))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getClockDisplay retrieves the current clock display settings
|
||||
func getClockDisplay(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
@@ -7,34 +7,29 @@ import (
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// discoverDevices handles device discovery command
|
||||
func discoverDevices(c *cli.Context) error {
|
||||
timeout := c.Duration("timeout")
|
||||
showAll := c.Bool("all")
|
||||
|
||||
fmt.Printf("Discovering SoundTouch devices...\n")
|
||||
|
||||
if showAll {
|
||||
fmt.Printf("Timeout: %v\n", timeout)
|
||||
fmt.Printf("Mode: Detailed information\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
// Override discovery timeout if provided
|
||||
if timeout > 0 {
|
||||
cfg.DiscoveryTimeout = timeout
|
||||
// Update config with CLI flags
|
||||
updateConfigFromCLI(c, cfg)
|
||||
|
||||
if c.Bool("all") {
|
||||
printDiscoveryContext(cfg)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Create discovery service
|
||||
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
@@ -48,18 +43,51 @@ func discoverDevices(c *cli.Context) error {
|
||||
}
|
||||
|
||||
if len(devices) == 0 {
|
||||
fmt.Println("No SoundTouch devices found on the network.")
|
||||
fmt.Println()
|
||||
fmt.Println("This could mean:")
|
||||
fmt.Println("- No SoundTouch devices are powered on")
|
||||
fmt.Println("- Devices are on a different network segment")
|
||||
fmt.Println("- Network blocks multicast traffic")
|
||||
fmt.Println("- Firewall is blocking discovery ports")
|
||||
|
||||
printNoDevicesMessage()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Display results
|
||||
printDiscoveryResults(devices, c.Bool("all"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateConfigFromCLI(c *cli.Context, cfg *config.Config) {
|
||||
if c.IsSet("timeout") {
|
||||
httpTimeout := c.Duration("timeout")
|
||||
cfg.HTTPTimeout = httpTimeout
|
||||
// Set discovery timeout to be 2x HTTP timeout (min 5s, max 30s)
|
||||
discoveryTimeout := httpTimeout * 2
|
||||
if discoveryTimeout < 5*time.Second {
|
||||
discoveryTimeout = 5 * time.Second
|
||||
}
|
||||
|
||||
if discoveryTimeout > 30*time.Second {
|
||||
discoveryTimeout = 30 * time.Second
|
||||
}
|
||||
|
||||
cfg.DiscoveryTimeout = discoveryTimeout
|
||||
}
|
||||
}
|
||||
|
||||
func printDiscoveryContext(cfg *config.Config) {
|
||||
fmt.Printf("HTTP Timeout: %v\n", cfg.HTTPTimeout)
|
||||
fmt.Printf("Discovery Timeout: %v\n", cfg.DiscoveryTimeout)
|
||||
fmt.Printf("Mode: Detailed information\n")
|
||||
}
|
||||
|
||||
func printNoDevicesMessage() {
|
||||
fmt.Println("No SoundTouch devices found on the network.")
|
||||
fmt.Println()
|
||||
fmt.Println("This could mean:")
|
||||
fmt.Println("- No SoundTouch devices are powered on")
|
||||
fmt.Println("- Devices are on a different network segment")
|
||||
fmt.Println("- Network blocks multicast traffic")
|
||||
fmt.Println("- Firewall is blocking discovery ports")
|
||||
}
|
||||
|
||||
func printDiscoveryResults(devices []*models.DiscoveredDevice, showAll bool) {
|
||||
fmt.Printf("Found %d SoundTouch device(s):\n\n", len(devices))
|
||||
|
||||
for i, device := range devices {
|
||||
@@ -71,11 +99,40 @@ func discoverDevices(c *cli.Context) error {
|
||||
fmt.Printf(" Serial: %s\n", device.SerialNo)
|
||||
}
|
||||
|
||||
if device.Location != "" {
|
||||
fmt.Printf(" Location: %s\n", device.Location)
|
||||
if device.APIBaseURL != "" {
|
||||
fmt.Printf(" API Base URL: %s\n", device.APIBaseURL)
|
||||
}
|
||||
|
||||
if device.InfoURL != "" {
|
||||
fmt.Printf(" Info URL: %s\n", device.InfoURL)
|
||||
}
|
||||
|
||||
if device.DiscoveryMethod != "" {
|
||||
fmt.Printf(" Discovery Method: %s\n", device.DiscoveryMethod)
|
||||
}
|
||||
|
||||
if showAll {
|
||||
// Show protocol-specific details in verbose mode
|
||||
if device.UPnPLocation != "" {
|
||||
fmt.Printf(" UPnP Location: %s\n", device.UPnPLocation)
|
||||
}
|
||||
|
||||
if device.UPnPUSN != "" {
|
||||
fmt.Printf(" UPnP USN: %s\n", device.UPnPUSN)
|
||||
}
|
||||
|
||||
if device.MDNSHostname != "" {
|
||||
fmt.Printf(" mDNS Hostname: %s\n", device.MDNSHostname)
|
||||
}
|
||||
|
||||
if device.MDNSService != "" {
|
||||
fmt.Printf(" mDNS Service: %s\n", device.MDNSService)
|
||||
}
|
||||
|
||||
if device.ConfigName != "" {
|
||||
fmt.Printf(" Config Name: %s\n", device.ConfigName)
|
||||
}
|
||||
|
||||
fmt.Printf(" Last Seen: %s\n", device.LastSeen.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
@@ -88,6 +145,4 @@ func discoverDevices(c *cli.Context) error {
|
||||
fmt.Println()
|
||||
fmt.Printf("Use any of these hosts with other commands:\n")
|
||||
fmt.Printf("Example: soundtouch-cli info --host %s\n", devices[0].Host)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
// Package main provides the soundtouch-cli events command for WebSocket event monitoring.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// eventSubscribe handles the events subscribe command
|
||||
func eventSubscribe(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
// Parse filters
|
||||
filterStr := c.String("filter")
|
||||
filters := parseEventFilters(filterStr)
|
||||
|
||||
debugMode, err := parseDebugMode(c.String("debug"))
|
||||
if err != nil {
|
||||
PrintError(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse duration
|
||||
duration := c.Duration("duration")
|
||||
verbose := c.Bool("verbose")
|
||||
reconnect := !c.Bool("no-reconnect")
|
||||
|
||||
PrintDeviceHeader("Starting WebSocket event monitoring", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
// Create SoundTouch client
|
||||
soundTouchClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Test basic connectivity
|
||||
fmt.Println("Testing device connectivity...")
|
||||
|
||||
deviceInfo, err := soundTouchClient.GetDeviceInfo()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to connect to device: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
macAddress := ""
|
||||
if len(deviceInfo.NetworkInfo) > 0 {
|
||||
macAddress = deviceInfo.NetworkInfo[0].MacAddress
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Connected to: %s (Type: %s, MAC: %s)\n",
|
||||
deviceInfo.Name, deviceInfo.Type, macAddress)
|
||||
|
||||
// Create WebSocket client
|
||||
wsClient := setupWebSocketClient(soundTouchClient, reconnect, verbose)
|
||||
|
||||
// Set up event handlers
|
||||
setupEventHandlers(wsClient, filters, verbose)
|
||||
|
||||
if debugMode != debugOff {
|
||||
installDebugHook(wsClient, debugMode)
|
||||
}
|
||||
|
||||
// Connect to WebSocket
|
||||
fmt.Println("🔌 Connecting to WebSocket...")
|
||||
|
||||
err = wsClient.Connect()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to connect to WebSocket: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("✅ Connected! Listening for events...")
|
||||
|
||||
if len(filters) > 0 {
|
||||
fmt.Printf("📋 Filtering events: %s\n", strings.Join(getFilterKeys(filters), ", "))
|
||||
}
|
||||
|
||||
if duration > 0 {
|
||||
fmt.Printf("⏰ Will listen for %v\n", duration)
|
||||
} else {
|
||||
fmt.Println("⏸️ Press Ctrl+C to stop")
|
||||
}
|
||||
|
||||
// Set up graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Handle duration limit
|
||||
if duration > 0 {
|
||||
go func() {
|
||||
select {
|
||||
case <-time.After(duration):
|
||||
fmt.Println("\n⏰ Duration limit reached, shutting down...")
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Handle interrupt signals
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case sig := <-sigChan:
|
||||
fmt.Printf("\n🛑 Received signal %v, shutting down...\n", sig)
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown
|
||||
<-ctx.Done()
|
||||
|
||||
// Disconnect WebSocket
|
||||
fmt.Println("🔌 Disconnecting...")
|
||||
|
||||
if err := wsClient.Disconnect(); err != nil {
|
||||
PrintError(fmt.Sprintf("Error during disconnect: %v", err))
|
||||
}
|
||||
|
||||
fmt.Println("✅ Disconnected successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// debugMode controls when the WebSocket subscribe loop prints raw frames
|
||||
// to stderr. "off" disables debug output entirely (the production default
|
||||
// when --debug is unset).
|
||||
type debugMode int
|
||||
|
||||
const (
|
||||
debugOff debugMode = iota
|
||||
debugAll
|
||||
debugUnknown
|
||||
debugErrors
|
||||
)
|
||||
|
||||
func parseDebugMode(s string) (debugMode, error) {
|
||||
switch strings.TrimSpace(s) {
|
||||
case "":
|
||||
return debugOff, nil
|
||||
case "all":
|
||||
return debugAll, nil
|
||||
case "unknown":
|
||||
return debugUnknown, nil
|
||||
case "errors":
|
||||
return debugErrors, nil
|
||||
default:
|
||||
return debugOff, fmt.Errorf("invalid --debug value %q (want one of: all, unknown, errors)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// installDebugHook wires an OnRawMessage handler that prints the raw
|
||||
// frame to stderr based on the chosen mode. Stays out of stdout so
|
||||
// debug output can be filtered/grep'd independently of normal events.
|
||||
func installDebugHook(ws *client.WebSocketClient, mode debugMode) {
|
||||
ws.OnRawMessage(func(data []byte, parseErr error) {
|
||||
switch mode {
|
||||
case debugAll:
|
||||
printRawFrame(data, parseErr, "all")
|
||||
case debugErrors:
|
||||
if parseErr != nil {
|
||||
printRawFrame(data, parseErr, "errors")
|
||||
}
|
||||
case debugUnknown:
|
||||
// "Unknown" = parsed successfully but no known event types
|
||||
// matched. Parse errors also qualify, since they're frames
|
||||
// the client couldn't interpret either.
|
||||
if parseErr != nil {
|
||||
printRawFrame(data, parseErr, "unknown:parse-error")
|
||||
return
|
||||
}
|
||||
|
||||
ev, err := models.ParseWebSocketEvent(data)
|
||||
if err != nil || len(ev.GetEventTypes()) == 0 {
|
||||
printRawFrame(data, err, "unknown")
|
||||
}
|
||||
case debugOff:
|
||||
// nothing
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func printRawFrame(data []byte, parseErr error, tag string) {
|
||||
prefix := "[ws-debug:" + tag + "]"
|
||||
if parseErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s parse-error: %v\n", prefix, parseErr)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n", prefix, string(data))
|
||||
}
|
||||
|
||||
// parseEventFilters validates and parses the filter string
|
||||
func parseEventFilters(eventFilter string) map[string]bool {
|
||||
validFilters := map[string]bool{
|
||||
"nowPlaying": true, "volume": true, "connection": true,
|
||||
"preset": true, "zone": true, "group": true, "bass": true,
|
||||
"sdkInfo": true, "userActivity": true,
|
||||
}
|
||||
|
||||
if eventFilter == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
filters := make(map[string]bool)
|
||||
filterList := strings.Split(eventFilter, ",")
|
||||
|
||||
for _, f := range filterList {
|
||||
f = strings.TrimSpace(f)
|
||||
if !validFilters[f] {
|
||||
PrintError(fmt.Sprintf("Invalid filter '%s'. Valid filters: %s",
|
||||
f, strings.Join(getFilterKeys(validFilters), ", ")))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
filters[f] = true
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
// setupWebSocketClient creates and configures the WebSocket client
|
||||
func setupWebSocketClient(soundTouchClient *client.Client, reconnect, verbose bool) *client.WebSocketClient {
|
||||
wsConfig := &client.WebSocketConfig{
|
||||
ReconnectInterval: 5 * time.Second,
|
||||
MaxReconnectAttempts: 0, // Unlimited if reconnect enabled
|
||||
PingInterval: 30 * time.Second,
|
||||
PongTimeout: 10 * time.Second,
|
||||
ReadBufferSize: 2048,
|
||||
WriteBufferSize: 2048,
|
||||
}
|
||||
|
||||
if verbose {
|
||||
wsConfig.Logger = &VerboseLogger{}
|
||||
} else {
|
||||
wsConfig.Logger = &SilentLogger{}
|
||||
}
|
||||
|
||||
if !reconnect {
|
||||
wsConfig.MaxReconnectAttempts = 1
|
||||
}
|
||||
|
||||
return soundTouchClient.NewWebSocketClient(wsConfig)
|
||||
}
|
||||
|
||||
// setupEventHandlers configures all event handlers
|
||||
func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]bool, verbose bool) {
|
||||
// Now Playing events
|
||||
if filters == nil || filters["nowPlaying"] {
|
||||
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
||||
handleNowPlayingEvent(event, verbose)
|
||||
})
|
||||
}
|
||||
|
||||
// Volume events
|
||||
if filters == nil || filters["volume"] {
|
||||
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
|
||||
handleVolumeEvent(event, verbose)
|
||||
})
|
||||
}
|
||||
|
||||
// Connection state events
|
||||
if filters == nil || filters["connection"] {
|
||||
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
|
||||
handleConnectionEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
// Preset events
|
||||
if filters == nil || filters["preset"] {
|
||||
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
|
||||
handlePresetEvent(event, verbose)
|
||||
})
|
||||
}
|
||||
|
||||
// Zone/Multiroom events
|
||||
if filters == nil || filters["zone"] {
|
||||
wsClient.OnZoneUpdated(func(event *models.ZoneUpdatedEvent) {
|
||||
handleZoneEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
// Stereo-pair (group) events — ST-10 only
|
||||
if filters == nil || filters["group"] {
|
||||
wsClient.OnGroupUpdated(func(event *models.GroupUpdatedEvent) {
|
||||
handleGroupEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
// Bass events
|
||||
if filters == nil || filters["bass"] {
|
||||
wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) {
|
||||
handleBassEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
// Special message handler
|
||||
wsClient.OnSpecialMessage(func(message *models.SpecialMessage) {
|
||||
handleSpecialMessage(message, filters, verbose)
|
||||
})
|
||||
|
||||
// Unknown events (always enabled for debugging)
|
||||
wsClient.OnUnknownEvent(func(event *models.WebSocketEvent) {
|
||||
handleUnknownEvent(event, verbose)
|
||||
})
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
func handleNowPlayingEvent(event *models.NowPlayingUpdatedEvent, verbose bool) {
|
||||
fmt.Printf("\n🎵 Now Playing Update [%s]:\n", event.DeviceID)
|
||||
np := &event.NowPlaying
|
||||
|
||||
if np.IsEmpty() {
|
||||
fmt.Println(" ⏹️ Nothing playing")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" 🎵 %s\n", np.GetDisplayTitle())
|
||||
|
||||
if artist := np.GetDisplayArtist(); artist != "" {
|
||||
fmt.Printf(" 👤 %s\n", artist)
|
||||
}
|
||||
|
||||
if np.Album != "" {
|
||||
fmt.Printf(" 💿 %s\n", np.Album)
|
||||
}
|
||||
|
||||
fmt.Printf(" 📻 Source: %s\n", np.Source)
|
||||
fmt.Printf(" ▶️ Status: %s\n", np.PlayStatus.String())
|
||||
|
||||
if np.HasTimeInfo() {
|
||||
fmt.Printf(" ⏱️ Duration: %s\n", np.FormatDuration())
|
||||
}
|
||||
|
||||
if np.ShuffleSetting != "" {
|
||||
fmt.Printf(" 🔀 Shuffle: %s\n", np.ShuffleSetting.String())
|
||||
}
|
||||
|
||||
if np.RepeatSetting != "" {
|
||||
fmt.Printf(" 🔁 Repeat: %s\n", np.RepeatSetting.String())
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Raw Source: %s, Account: %s\n", np.Source, np.SourceAccount)
|
||||
|
||||
if np.Art != nil && np.Art.URL != "" {
|
||||
fmt.Printf(" 🖼️ Artwork: %s\n", np.Art.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleVolumeEvent(event *models.VolumeUpdatedEvent, verbose bool) {
|
||||
vol := &event.Volume
|
||||
fmt.Printf("\n🔊 Volume Update [%s]:\n", event.DeviceID)
|
||||
|
||||
if vol.IsMuted() {
|
||||
fmt.Println(" 🔇 Muted")
|
||||
} else {
|
||||
fmt.Printf(" 🔊 Level: %d\n", vol.ActualVolume)
|
||||
|
||||
if vol.TargetVolume != vol.ActualVolume {
|
||||
fmt.Printf(" 🎯 Target: %d\n", vol.TargetVolume)
|
||||
}
|
||||
|
||||
fmt.Printf(" 📊 %s\n", models.GetVolumeLevelName(vol.ActualVolume))
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Sync: %v\n", vol.IsVolumeSync())
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnectionEvent(event *models.ConnectionStateUpdatedEvent) {
|
||||
cs := &event.ConnectionState
|
||||
fmt.Printf("\n🌐 Connection Update [%s]:\n", event.DeviceID)
|
||||
|
||||
if cs.IsConnected() {
|
||||
fmt.Println(" ✅ Connected")
|
||||
} else {
|
||||
fmt.Printf(" ❌ State: %s\n", cs.State)
|
||||
}
|
||||
|
||||
if cs.Signal != "" {
|
||||
fmt.Printf(" 📶 Signal: %s\n", cs.GetSignalStrength())
|
||||
}
|
||||
}
|
||||
|
||||
func handlePresetEvent(event *models.PresetUpdatedEvent, verbose bool) {
|
||||
presets := &event.Presets
|
||||
|
||||
deviceHeader := "\n📻 Presets Update"
|
||||
if event.DeviceID != "" {
|
||||
deviceHeader += fmt.Sprintf(" [%s]", event.DeviceID)
|
||||
}
|
||||
|
||||
fmt.Printf("%s:\n", deviceHeader)
|
||||
fmt.Printf(" 📻 Total presets: %d\n", len(presets.Preset))
|
||||
|
||||
for _, preset := range presets.Preset {
|
||||
fmt.Printf(" 📻 Preset %d:", preset.ID)
|
||||
|
||||
if preset.ContentItem != nil {
|
||||
fmt.Printf(" %s", preset.ContentItem.ItemName)
|
||||
fmt.Printf(" (%s)", preset.ContentItem.Source)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Raw presets data: %d total presets\n", len(presets.Preset))
|
||||
}
|
||||
}
|
||||
|
||||
func handleZoneEvent(event *models.ZoneUpdatedEvent) {
|
||||
zone := &event.Zone
|
||||
fmt.Printf("\n🏠 Zone Update [%s]:\n", event.DeviceID)
|
||||
fmt.Printf(" 👑 Master: %s\n", zone.Master)
|
||||
|
||||
if len(zone.Members) > 0 {
|
||||
fmt.Printf(" 👥 Members (%d):\n", len(zone.Members))
|
||||
|
||||
for i, member := range zone.Members {
|
||||
fmt.Printf(" %d. %s (%s)\n", i+1, member.DeviceID, member.IP)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" 👤 Single device (no zone)")
|
||||
}
|
||||
}
|
||||
|
||||
func handleGroupEvent(event *models.GroupUpdatedEvent) {
|
||||
group := &event.Group
|
||||
fmt.Printf("\n🎧 Stereo-Pair Update [%s]:\n", event.DeviceID)
|
||||
|
||||
if group.IsEmpty() {
|
||||
fmt.Println(" ⛓️💥 Pair dissolved (no group configured)")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" 🆔 ID: %s\n", group.ID)
|
||||
fmt.Printf(" 📛 Name: %s\n", group.Name)
|
||||
fmt.Printf(" 👑 Master: %s\n", group.MasterDeviceID)
|
||||
|
||||
if group.Status != "" {
|
||||
fmt.Printf(" ✅ Status: %s\n", group.Status)
|
||||
}
|
||||
|
||||
for _, r := range group.Roles.Roles {
|
||||
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
|
||||
|
||||
if r.IPAddress != "" {
|
||||
fmt.Printf(" (IP: %s)", r.IPAddress)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func handleBassEvent(event *models.BassUpdatedEvent) {
|
||||
bass := &event.Bass
|
||||
fmt.Printf("\n🎵 Bass Update [%s]:\n", event.DeviceID)
|
||||
fmt.Printf(" 🎚️ Level: %d\n", bass.ActualBass)
|
||||
|
||||
if bass.TargetBass != bass.ActualBass {
|
||||
fmt.Printf(" 🎯 Target: %d\n", bass.TargetBass)
|
||||
}
|
||||
|
||||
levelDesc := "Neutral"
|
||||
if bass.ActualBass > 0 {
|
||||
levelDesc = "Boosted"
|
||||
} else if bass.ActualBass < 0 {
|
||||
levelDesc = "Reduced"
|
||||
}
|
||||
|
||||
fmt.Printf(" 📊 %s\n", levelDesc)
|
||||
}
|
||||
|
||||
func handleSpecialMessage(message *models.SpecialMessage, filters map[string]bool, verbose bool) {
|
||||
// Check if we should filter this message type
|
||||
if filters != nil {
|
||||
switch message.Type {
|
||||
case models.MessageTypeSdkInfo:
|
||||
if !filters["sdkInfo"] {
|
||||
return
|
||||
}
|
||||
case models.MessageTypeUserActivity:
|
||||
if !filters["userActivity"] {
|
||||
return
|
||||
}
|
||||
case models.MessageTypeUserInactivity:
|
||||
if !filters["userInactivity"] {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch message.Type {
|
||||
case models.MessageTypeSdkInfo:
|
||||
if sdkInfo := message.GetSdkInfo(); sdkInfo != nil {
|
||||
fmt.Printf("\n📡 SDK Info:\n")
|
||||
fmt.Printf(" 📋 Server Version: %s\n", sdkInfo.ServerVersion)
|
||||
fmt.Printf(" 🔧 Server Build: %s\n", sdkInfo.ServerBuild)
|
||||
}
|
||||
case models.MessageTypeUserActivity:
|
||||
fmt.Printf("\n👤 User Activity [%s]\n", message.DeviceID)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
||||
}
|
||||
case models.MessageTypeUserInactivity:
|
||||
fmt.Printf("\n💤 User Inactivity [%s]\n", message.DeviceID)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
||||
}
|
||||
default:
|
||||
fmt.Printf("\n❓ Unknown Special Message: %s\n", message.String())
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Raw data: %s\n", string(message.RawData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleUnknownEvent(event *models.WebSocketEvent, verbose bool) {
|
||||
fmt.Printf("\n❓ Unknown Event [%s]:\n", event.DeviceID)
|
||||
types := event.GetEventTypes()
|
||||
|
||||
for _, eventType := range types {
|
||||
fmt.Printf(" 📝 Type: %s\n", eventType)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
events := event.GetEvents()
|
||||
fmt.Printf(" 📱 Event count: %d\n", len(events))
|
||||
fmt.Printf(" ⏰ Timestamp: %s\n", event.Timestamp.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// getFilterKeys extracts keys from filter map
|
||||
func getFilterKeys(filters map[string]bool) []string {
|
||||
var keys []string
|
||||
for k := range filters {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
// Logger implementations
|
||||
type VerboseLogger struct{}
|
||||
|
||||
func (v *VerboseLogger) Printf(format string, args ...interface{}) {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
type SilentLogger struct{}
|
||||
|
||||
func (s *SilentLogger) Printf(_ string, _ ...interface{}) {
|
||||
// Do nothing - silent logging
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseEventFilters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eventFilter string
|
||||
want map[string]bool
|
||||
expectExit bool
|
||||
}{
|
||||
{
|
||||
name: "empty filter",
|
||||
eventFilter: "",
|
||||
want: nil,
|
||||
expectExit: false,
|
||||
},
|
||||
{
|
||||
name: "single valid filter",
|
||||
eventFilter: "nowPlaying",
|
||||
want: map[string]bool{"nowPlaying": true},
|
||||
expectExit: false,
|
||||
},
|
||||
{
|
||||
name: "multiple valid filters",
|
||||
eventFilter: "nowPlaying,volume,bass",
|
||||
want: map[string]bool{"nowPlaying": true, "volume": true, "bass": true},
|
||||
expectExit: false,
|
||||
},
|
||||
{
|
||||
name: "filters with spaces",
|
||||
eventFilter: "nowPlaying, volume , bass",
|
||||
want: map[string]bool{"nowPlaying": true, "volume": true, "bass": true},
|
||||
expectExit: false,
|
||||
},
|
||||
{
|
||||
name: "all valid filters",
|
||||
eventFilter: "nowPlaying,volume,connection,preset,zone,bass,sdkInfo,userActivity",
|
||||
want: map[string]bool{
|
||||
"nowPlaying": true,
|
||||
"volume": true,
|
||||
"connection": true,
|
||||
"preset": true,
|
||||
"zone": true,
|
||||
"bass": true,
|
||||
"sdkInfo": true,
|
||||
"userActivity": true,
|
||||
},
|
||||
expectExit: false,
|
||||
},
|
||||
{
|
||||
name: "duplicate filters",
|
||||
eventFilter: "volume,volume,bass",
|
||||
want: map[string]bool{"volume": true, "bass": true},
|
||||
expectExit: false,
|
||||
},
|
||||
{
|
||||
name: "single invalid filter - should exit",
|
||||
eventFilter: "invalidFilter",
|
||||
want: nil,
|
||||
expectExit: true,
|
||||
},
|
||||
{
|
||||
name: "mixed valid and invalid - should exit",
|
||||
eventFilter: "nowPlaying,invalidFilter,volume",
|
||||
want: nil,
|
||||
expectExit: true,
|
||||
},
|
||||
{
|
||||
name: "comma only",
|
||||
eventFilter: ",",
|
||||
want: nil,
|
||||
expectExit: true,
|
||||
},
|
||||
{
|
||||
name: "trailing comma",
|
||||
eventFilter: "nowPlaying,volume,",
|
||||
want: nil,
|
||||
expectExit: true,
|
||||
},
|
||||
{
|
||||
name: "leading comma",
|
||||
eventFilter: ",nowPlaying,volume",
|
||||
want: nil,
|
||||
expectExit: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.expectExit {
|
||||
// For test cases that should exit, we can't easily test the os.Exit call
|
||||
// So we'll just test that invalid filters exist in the input
|
||||
if tt.eventFilter == "" {
|
||||
return // Empty filter is valid
|
||||
}
|
||||
|
||||
// Check if the filter contains any invalid values
|
||||
hasInvalid := false
|
||||
|
||||
if tt.eventFilter != "" {
|
||||
if strings.Contains(tt.eventFilter, "invalidFilter") ||
|
||||
strings.Contains(tt.eventFilter, ",,") ||
|
||||
strings.HasPrefix(tt.eventFilter, ",") ||
|
||||
strings.HasSuffix(tt.eventFilter, ",") ||
|
||||
tt.eventFilter == "," {
|
||||
hasInvalid = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasInvalid && tt.expectExit {
|
||||
t.Errorf("Expected invalid filter but didn't find one in: %s", tt.eventFilter)
|
||||
}
|
||||
} else {
|
||||
// We can't easily test the actual function since it calls os.Exit on invalid input
|
||||
// Instead, we'll test the logic manually
|
||||
if tt.eventFilter == "" {
|
||||
if tt.want != nil {
|
||||
t.Errorf("parseEventFilters() = %v, want %v", nil, tt.want)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate the parsing logic
|
||||
filters := make(map[string]bool)
|
||||
validFilters := map[string]bool{
|
||||
"nowPlaying": true, "volume": true, "connection": true,
|
||||
"preset": true, "zone": true, "bass": true,
|
||||
"sdkInfo": true, "userActivity": true,
|
||||
}
|
||||
|
||||
parts := []string{}
|
||||
|
||||
for _, part := range []string{tt.eventFilter} {
|
||||
// Simple split simulation
|
||||
switch part {
|
||||
case "nowPlaying,volume,bass":
|
||||
parts = []string{"nowPlaying", "volume", "bass"}
|
||||
case "nowPlaying, volume , bass":
|
||||
parts = []string{"nowPlaying", " volume ", " bass"}
|
||||
case "nowPlaying,volume,connection,preset,zone,bass,sdkInfo,userActivity":
|
||||
parts = []string{"nowPlaying", "volume", "connection", "preset", "zone", "bass", "sdkInfo", "userActivity"}
|
||||
case "volume,volume,bass":
|
||||
parts = []string{"volume", "volume", "bass"}
|
||||
default:
|
||||
parts = []string{part}
|
||||
}
|
||||
}
|
||||
|
||||
allValid := true
|
||||
|
||||
for _, f := range parts {
|
||||
f = strings.TrimSpace(f)
|
||||
if f == "" {
|
||||
allValid = false
|
||||
break
|
||||
}
|
||||
|
||||
if !validFilters[f] {
|
||||
allValid = false
|
||||
break
|
||||
}
|
||||
|
||||
filters[f] = true
|
||||
}
|
||||
|
||||
if allValid && !reflect.DeepEqual(filters, tt.want) {
|
||||
t.Errorf("parseEventFilters() = %v, want %v", filters, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilterKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filters map[string]bool
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "nil map",
|
||||
filters: nil,
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "empty map",
|
||||
filters: map[string]bool{},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "single filter",
|
||||
filters: map[string]bool{"nowPlaying": true},
|
||||
want: []string{"nowPlaying"},
|
||||
},
|
||||
{
|
||||
name: "multiple filters",
|
||||
filters: map[string]bool{"nowPlaying": true, "volume": true, "bass": true},
|
||||
want: []string{"nowPlaying", "volume", "bass"},
|
||||
},
|
||||
{
|
||||
name: "all filters",
|
||||
filters: map[string]bool{
|
||||
"nowPlaying": true,
|
||||
"volume": true,
|
||||
"connection": true,
|
||||
"preset": true,
|
||||
"zone": true,
|
||||
"bass": true,
|
||||
"sdkInfo": true,
|
||||
"userActivity": true,
|
||||
},
|
||||
want: []string{"nowPlaying", "volume", "connection", "preset", "zone", "bass", "sdkInfo", "userActivity"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := getFilterKeys(tt.filters)
|
||||
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("getFilterKeys() returned %d keys, want %d", len(got), len(tt.want))
|
||||
}
|
||||
|
||||
// Convert to map for easier comparison since order doesn't matter
|
||||
gotMap := make(map[string]bool)
|
||||
for _, key := range got {
|
||||
gotMap[key] = true
|
||||
}
|
||||
|
||||
wantMap := make(map[string]bool)
|
||||
for _, key := range tt.want {
|
||||
wantMap[key] = true
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(gotMap, wantMap) {
|
||||
t.Errorf("getFilterKeys() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test event handler setup logic
|
||||
func TestEventHandlerTypes(t *testing.T) {
|
||||
// Test that we have all the expected event types defined
|
||||
validEventTypes := []string{
|
||||
"nowPlaying",
|
||||
"volume",
|
||||
"connection",
|
||||
"preset",
|
||||
"zone",
|
||||
"bass",
|
||||
"sdkInfo",
|
||||
"userActivity",
|
||||
}
|
||||
|
||||
// Verify all event types are accounted for
|
||||
eventTypeMap := map[string]bool{
|
||||
"nowPlaying": true, "volume": true, "connection": true,
|
||||
"preset": true, "zone": true, "bass": true,
|
||||
"sdkInfo": true, "userActivity": true,
|
||||
}
|
||||
|
||||
for _, eventType := range validEventTypes {
|
||||
if !eventTypeMap[eventType] {
|
||||
t.Errorf("Event type %s is not in the valid event types map", eventType)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we have exactly 8 event types
|
||||
if len(validEventTypes) != 8 {
|
||||
t.Errorf("Expected 8 event types, got %d", len(validEventTypes))
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark filter parsing performance
|
||||
func BenchmarkParseEventFilters(b *testing.B) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
filter string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"single", "nowPlaying"},
|
||||
{"multiple", "nowPlaying,volume,bass"},
|
||||
{"all_filters", "nowPlaying,volume,connection,preset,zone,bass,sdkInfo,userActivity"},
|
||||
{"with_spaces", "nowPlaying, volume , bass"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
b.Run(tc.name, func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
// We can't benchmark the actual function due to os.Exit calls
|
||||
// So we benchmark the core logic
|
||||
if tc.filter == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
filters := make(map[string]bool)
|
||||
// Simulate string splitting and processing
|
||||
for _, f := range []string{"nowPlaying", "volume", "bass"} {
|
||||
filters[f] = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test WebSocket configuration defaults
|
||||
func TestWebSocketConfigDefaults(t *testing.T) {
|
||||
// This tests the configuration values used in setupWebSocketClient
|
||||
// We can't easily unit test the actual function without mocking the client
|
||||
// But we can test that our expected defaults are reasonable
|
||||
defaultReconnectInterval := 5000000000 // 5 seconds in nanoseconds
|
||||
defaultPingInterval := 30000000000 // 30 seconds in nanoseconds
|
||||
defaultPongTimeout := 10000000000 // 10 seconds in nanoseconds
|
||||
defaultBufferSize := 2048
|
||||
|
||||
if defaultReconnectInterval < 1000000000 { // Less than 1 second
|
||||
t.Error("Reconnect interval should be at least 1 second")
|
||||
}
|
||||
|
||||
if defaultPingInterval < 10000000000 { // Less than 10 seconds
|
||||
t.Error("Ping interval should be at least 10 seconds")
|
||||
}
|
||||
|
||||
if defaultPongTimeout < 1000000000 { // Less than 1 second
|
||||
t.Error("Pong timeout should be at least 1 second")
|
||||
}
|
||||
|
||||
if defaultBufferSize < 1024 {
|
||||
t.Error("Buffer size should be at least 1024 bytes")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// getGroupStatus retrieves and prints the device's current stereo-pair state.
|
||||
func getGroupStatus(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Getting group information", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
group, err := client.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if group.IsEmpty() {
|
||||
fmt.Println("Device is not in a stereo pair")
|
||||
return nil
|
||||
}
|
||||
|
||||
printGroup(group)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createGroup forms a stereo pair by POSTing /addGroup to both speakers in
|
||||
// parallel. LEFT is the master. Addressing each speaker directly (instead of
|
||||
// only the master and letting it propagate via marge) sidesteps the
|
||||
// inter-device round-trip that surfaced as client timeouts in #252.
|
||||
func createGroup(c *cli.Context) error {
|
||||
leftIP := c.String("left")
|
||||
rightIP := c.String("right")
|
||||
name := c.String("name")
|
||||
|
||||
if net.ParseIP(leftIP) == nil {
|
||||
PrintError(fmt.Sprintf("Invalid left IP address: %s", leftIP))
|
||||
return fmt.Errorf("invalid left IP: %s", leftIP)
|
||||
}
|
||||
|
||||
if net.ParseIP(rightIP) == nil {
|
||||
PrintError(fmt.Sprintf("Invalid right IP address: %s", rightIP))
|
||||
return fmt.Errorf("invalid right IP: %s", rightIP)
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Creating stereo pair: LEFT=%s RIGHT=%s", leftIP, rightIP), leftIP, speaker.HTTPPort)
|
||||
|
||||
leftInfo, err := fetchDeviceInfo(c, leftIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read LEFT device info: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
rightInfo, err := fetchDeviceInfo(c, rightIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read RIGHT device info: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s + %s", leftInfo.Name, rightInfo.Name)
|
||||
}
|
||||
|
||||
req := &models.Group{
|
||||
Name: name,
|
||||
MasterDeviceID: leftInfo.DeviceID,
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: leftInfo.DeviceID, Role: "LEFT", IPAddress: leftIP},
|
||||
{DeviceID: rightInfo.DeviceID, Role: "RIGHT", IPAddress: rightIP},
|
||||
},
|
||||
},
|
||||
// SenderIPAddress is intentionally omitted on the base request.
|
||||
// propagateAddGroup adds it to the slave's copy only — see comment there.
|
||||
}
|
||||
|
||||
leftClient, err := clientForHost(c, leftIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client for LEFT: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
rightClient, err := clientForHost(c, rightIP)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client for RIGHT: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, leftIP, rightIP, req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
PrintError(fmt.Sprintf("LEFT (%s) /addGroup failed: %v", leftIP, leftOut.err))
|
||||
}
|
||||
|
||||
if rightOut.err != nil {
|
||||
PrintError(fmt.Sprintf("RIGHT (%s) /addGroup failed: %v", rightIP, rightOut.err))
|
||||
}
|
||||
|
||||
if leftOut.err != nil || rightOut.err != nil {
|
||||
if (leftOut.err == nil) != (rightOut.err == nil) {
|
||||
succeeded := leftIP
|
||||
if leftOut.err != nil {
|
||||
succeeded = rightIP
|
||||
}
|
||||
|
||||
PrintError(fmt.Sprintf("Partial group state on %s — clean up with `soundtouch-cli --host %s group remove`", succeeded, succeeded))
|
||||
}
|
||||
|
||||
return fmt.Errorf("/addGroup propagation failed")
|
||||
}
|
||||
|
||||
// The LEFT (master) response carries the assigned group ID; use it for display.
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair created (id=%s)", leftOut.group.ID))
|
||||
printGroup(leftOut.group)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addGroupOutcome is the per-speaker result of a parallel /addGroup call.
|
||||
type addGroupOutcome struct {
|
||||
host string
|
||||
group *models.Group
|
||||
err error
|
||||
}
|
||||
|
||||
// propagateAddGroup POSTs /addGroup to both speakers concurrently and returns
|
||||
// the (LEFT, RIGHT) outcomes. A non-GROUP_OK Status in the response is
|
||||
// reported as an error so callers don't have to re-inspect the body.
|
||||
//
|
||||
// The two POSTs carry different payloads: the master (LEFT) receives the base
|
||||
// request with no senderIPAddress so its state machine forms the group as the
|
||||
// master, while the slave (RIGHT) receives a copy with senderIPAddress set to
|
||||
// the master's IP so its state machine joins as the slave. Sending the same
|
||||
// payload to both makes both speakers think they're the slave — they enter
|
||||
// AddingSlave, wait for a master that never confirms, time out after 5 s, and
|
||||
// revert (issue #252).
|
||||
func propagateAddGroup(left, right *client.Client, leftIP, rightIP string, req *models.Group) (addGroupOutcome, addGroupOutcome) {
|
||||
masterReq := *req
|
||||
masterReq.SenderIPAddress = ""
|
||||
|
||||
slaveReq := *req
|
||||
slaveReq.SenderIPAddress = leftIP
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
leftOut, rightOut addGroupOutcome
|
||||
)
|
||||
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
leftOut = postAddGroup(left, leftIP, &masterReq)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
rightOut = postAddGroup(right, rightIP, &slaveReq)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return leftOut, rightOut
|
||||
}
|
||||
|
||||
func postAddGroup(cli *client.Client, host string, req *models.Group) addGroupOutcome {
|
||||
out := addGroupOutcome{host: host}
|
||||
|
||||
g, err := cli.AddGroup(req)
|
||||
if err != nil {
|
||||
out.err = err
|
||||
return out
|
||||
}
|
||||
|
||||
out.group = g
|
||||
|
||||
if g != nil && g.Status != "" && g.Status != "GROUP_OK" {
|
||||
out.err = fmt.Errorf("device returned status %q (want GROUP_OK)", g.Status)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// renameGroup updates the name of the existing stereo pair. The device
|
||||
// requires the full structure on every update, so we fetch the current
|
||||
// state first.
|
||||
func renameGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
newName := c.String("name")
|
||||
|
||||
if newName == "" {
|
||||
PrintError("--name is required")
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Renaming stereo pair to %q", newName), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
current, err := stClient.GetGroup()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if current.IsEmpty() {
|
||||
PrintError("Device is not in a stereo pair — nothing to rename")
|
||||
return fmt.Errorf("no group configured")
|
||||
}
|
||||
|
||||
// Status is read-only on the device side; don't echo it back.
|
||||
current.Status = ""
|
||||
current.Name = newName
|
||||
|
||||
result, err := stClient.UpdateGroup(current)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to rename group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Stereo pair renamed to %q", result.Name))
|
||||
printGroup(result)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeGroup tears down the device's stereo pair.
|
||||
func removeGroup(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
stClient, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := stClient.RemoveGroup(); err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove group: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess("Stereo pair removed")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchDeviceInfo builds a one-off client for the given IP and reads /info.
|
||||
// Reused for both halves of a `create` invocation so the caller doesn't have
|
||||
// to babysit two host/port pairs.
|
||||
func fetchDeviceInfo(c *cli.Context, host string) (*models.DeviceInfo, error) {
|
||||
stClient, err := clientForHost(c, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stClient.GetDeviceInfo()
|
||||
}
|
||||
|
||||
// clientForHost mirrors CreateSoundTouchClient but overrides the host so we
|
||||
// can talk to a speaker other than the one named in --host.
|
||||
func clientForHost(c *cli.Context, host string) (*client.Client, error) {
|
||||
cfg, err := loadConfig(c.Duration("timeout"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
return client.NewClient(&client.Config{
|
||||
Host: host,
|
||||
Port: speaker.HTTPPort,
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
UserAgent: cfg.UserAgent,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func printGroup(g *models.Group) {
|
||||
fmt.Println("Stereo Pair Configuration:")
|
||||
fmt.Printf(" ID: %s\n", g.ID)
|
||||
fmt.Printf(" Name: %s\n", g.Name)
|
||||
fmt.Printf(" Master: %s\n", g.MasterDeviceID)
|
||||
|
||||
if g.Status != "" {
|
||||
fmt.Printf(" Status: %s\n", g.Status)
|
||||
}
|
||||
|
||||
for _, r := range g.Roles.Roles {
|
||||
fmt.Printf(" %-5s %s", r.Role, r.DeviceID)
|
||||
|
||||
if r.IPAddress != "" {
|
||||
fmt.Printf(" (IP: %s)", r.IPAddress)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// happyAddGroupServer fakes a speaker's /addGroup that echoes the request
|
||||
// with an assigned ID and GROUP_OK status, matching real hardware behaviour.
|
||||
func happyAddGroupServer(t *testing.T, assignedID string) (*httptest.Server, *[]string) {
|
||||
t.Helper()
|
||||
|
||||
bodies := make([]string, 0)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/addGroup" || r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
bodies = append(bodies, string(body))
|
||||
|
||||
var got models.Group
|
||||
if err := xml.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
|
||||
got.ID = assignedID
|
||||
got.Status = "GROUP_OK"
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
enc, _ := xml.Marshal(&got)
|
||||
_, _ = w.Write(enc)
|
||||
}))
|
||||
|
||||
return srv, &bodies
|
||||
}
|
||||
|
||||
func newTestGroupClient(serverURL string) *client.Client {
|
||||
return client.NewClientFromHost(serverURL)
|
||||
}
|
||||
|
||||
func sampleGroupRequest(leftIP, rightIP string) *models.Group {
|
||||
return &models.Group{
|
||||
Name: "Living Room",
|
||||
MasterDeviceID: "9070658C9D4A",
|
||||
Roles: models.GroupRoles{
|
||||
Roles: []models.GroupRole{
|
||||
{DeviceID: "9070658C9D4A", Role: "LEFT", IPAddress: leftIP},
|
||||
{DeviceID: "F45EAB3115DA", Role: "RIGHT", IPAddress: rightIP},
|
||||
},
|
||||
},
|
||||
// senderIPAddress is intentionally not set here; propagateAddGroup
|
||||
// adds it to the slave's copy only.
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_BothSucceed(t *testing.T) {
|
||||
leftSrv, leftBodies := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
|
||||
rightSrv, rightBodies := happyAddGroupServer(t, "9999999")
|
||||
defer rightSrv.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.168.1.131", "192.168.1.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.168.1.131", "192.168.1.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
}
|
||||
|
||||
if rightOut.err != nil {
|
||||
t.Errorf("RIGHT err = %v, want nil", rightOut.err)
|
||||
}
|
||||
|
||||
if leftOut.group == nil || leftOut.group.ID != "9999999" || leftOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("LEFT group = %+v, want id=9999999 status=GROUP_OK", leftOut.group)
|
||||
}
|
||||
|
||||
if rightOut.group == nil || rightOut.group.Status != "GROUP_OK" {
|
||||
t.Errorf("RIGHT group = %+v, want status=GROUP_OK", rightOut.group)
|
||||
}
|
||||
|
||||
// Both speakers must have received the roles, but only the slave's payload
|
||||
// carries senderIPAddress — see propagateAddGroup for the why.
|
||||
for label, bodies := range map[string]*[]string{"LEFT": leftBodies, "RIGHT": rightBodies} {
|
||||
if len(*bodies) != 1 {
|
||||
t.Fatalf("%s: expected exactly one POST, got %d", label, len(*bodies))
|
||||
}
|
||||
|
||||
body := (*bodies)[0]
|
||||
for _, want := range []string{"<role>LEFT</role>", "<role>RIGHT</role>"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%s body missing %q\nbody:\n%s", label, want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
leftBody := (*leftBodies)[0]
|
||||
if strings.Contains(leftBody, "<senderIPAddress>") {
|
||||
t.Errorf("LEFT (master) body must NOT carry <senderIPAddress>, otherwise the master flips into slave mode (issue #252)\nbody:\n%s", leftBody)
|
||||
}
|
||||
|
||||
rightBody := (*rightBodies)[0]
|
||||
if !strings.Contains(rightBody, "<senderIPAddress>192.168.1.131</senderIPAddress>") {
|
||||
t.Errorf("RIGHT (slave) body must carry <senderIPAddress>192.168.1.131</senderIPAddress>\nbody:\n%s", rightBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropagateAddGroup_RightFails(t *testing.T) {
|
||||
leftSrv, _ := happyAddGroupServer(t, "9999999")
|
||||
defer leftSrv.Close()
|
||||
|
||||
rightSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer rightSrv.Close()
|
||||
|
||||
leftClient := newTestGroupClient(leftSrv.URL)
|
||||
rightClient := newTestGroupClient(rightSrv.URL)
|
||||
|
||||
req := sampleGroupRequest("192.168.1.131", "192.168.1.134")
|
||||
|
||||
leftOut, rightOut := propagateAddGroup(leftClient, rightClient, "192.168.1.131", "192.168.1.134", req)
|
||||
|
||||
if leftOut.err != nil {
|
||||
t.Errorf("LEFT err = %v, want nil", leftOut.err)
|
||||
}
|
||||
|
||||
if rightOut.err == nil {
|
||||
t.Error("RIGHT err = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_StatusOtherThanGroupOKIsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group><status>GROUP_NOT_READY</status></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err == nil {
|
||||
t.Fatal("expected error for non-GROUP_OK status")
|
||||
}
|
||||
|
||||
if !strings.Contains(out.err.Error(), "GROUP_NOT_READY") {
|
||||
t.Errorf("error %q does not mention returned status", out.err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostAddGroup_EmptyStatusIsAccepted(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group id="42"><name>n</name></group>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
out := postAddGroup(newTestGroupClient(srv.URL), "test", sampleGroupRequest("1.1.1.1", "2.2.2.2"))
|
||||
|
||||
if out.err != nil {
|
||||
t.Errorf("err = %v, want nil for empty status (some firmware omits it)", out.err)
|
||||
}
|
||||
|
||||
if out.group == nil || out.group.ID != "42" {
|
||||
t.Errorf("group = %+v, want id=42", out.group)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
@@ -207,11 +208,10 @@ func getPresets(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectPreset selects a preset by number (1-6)
|
||||
func selectPreset(c *cli.Context) error {
|
||||
presetNum := c.Int("preset")
|
||||
// getSupportedURLs handles getting supported URLs/endpoints
|
||||
func getSupportedURLs(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Selecting preset %d", presetNum), clientConfig.Host, clientConfig.Port)
|
||||
PrintDeviceHeader("Getting supported URLs", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
@@ -219,22 +219,455 @@ func selectPreset(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SelectPreset(presetNum)
|
||||
supportedURLs, err := client.GetSupportedURLs()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to select preset: %v", err))
|
||||
PrintError(fmt.Sprintf("Failed to get supported URLs: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Preset %d selected", presetNum))
|
||||
printSupportedURLs(supportedURLs, c)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printSupportedURLs formats and displays supported URLs information
|
||||
func printSupportedURLs(supportedURLs *models.SupportedURLsResponse, c *cli.Context) {
|
||||
verbose := c.Bool("verbose")
|
||||
showFeatures := c.Bool("features")
|
||||
|
||||
fmt.Printf("Device Supported URLs:\n")
|
||||
fmt.Printf(" Device ID: %s\n", supportedURLs.DeviceID)
|
||||
fmt.Printf(" Total Endpoints: %d\n", supportedURLs.GetURLCount())
|
||||
|
||||
// Show feature completeness score
|
||||
completeness, supported, total := supportedURLs.GetFeatureCompleteness()
|
||||
fmt.Printf(" Feature Coverage: %d%% (%d/%d features)\n\n", completeness, supported, total)
|
||||
|
||||
if showFeatures || (!verbose && !showFeatures) {
|
||||
// Show feature mapping (default view)
|
||||
printFeatureMapping(supportedURLs, verbose)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Println()
|
||||
printDetailedEndpoints(supportedURLs)
|
||||
}
|
||||
|
||||
if !showFeatures && !verbose {
|
||||
fmt.Printf("\n💡 Options:\n")
|
||||
fmt.Printf(" --features Show detailed feature mapping and CLI commands\n")
|
||||
fmt.Printf(" --verbose Show complete endpoint list\n")
|
||||
}
|
||||
}
|
||||
|
||||
// printFeatureMapping displays the feature-to-endpoint mapping
|
||||
func printFeatureMapping(supportedURLs *models.SupportedURLsResponse, verbose bool) {
|
||||
fmt.Printf("🎯 Device Feature Support:\n\n")
|
||||
|
||||
// Get features organized by category
|
||||
featuresByCategory := supportedURLs.GetFeaturesByCategory()
|
||||
printFeatureCategories(featuresByCategory, supportedURLs, verbose)
|
||||
printMissingEssentialFeatures(supportedURLs)
|
||||
printPartiallyImplementedFeatures(supportedURLs, verbose)
|
||||
}
|
||||
|
||||
func printFeatureCategories(featuresByCategory map[string][]models.EndpointFeature, supportedURLs *models.SupportedURLsResponse, verbose bool) {
|
||||
categoryInfo := map[string]string{
|
||||
"Core": "⚡",
|
||||
"Audio": "🔊",
|
||||
"Playback": "▶️",
|
||||
"Sources": "📱",
|
||||
"Content": "📻",
|
||||
"Presets": "⭐",
|
||||
"Multiroom": "🏠",
|
||||
"Network": "🌐",
|
||||
"System": "⚙️",
|
||||
}
|
||||
|
||||
categoryOrder := []string{"Core", "Audio", "Playback", "Sources", "Content", "Presets", "Multiroom", "Network", "System"}
|
||||
|
||||
for _, category := range categoryOrder {
|
||||
features := featuresByCategory[category]
|
||||
if len(features) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
emoji := categoryInfo[category]
|
||||
fmt.Printf("%s %s (%d features):\n", emoji, category, len(features))
|
||||
|
||||
for _, feature := range features {
|
||||
printFeatureStatus(feature, supportedURLs, verbose)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func printFeatureStatus(feature models.EndpointFeature, supportedURLs *models.SupportedURLsResponse, verbose bool) {
|
||||
supportedEndpoints := countSupportedEndpoints(feature, supportedURLs)
|
||||
|
||||
status := "✅"
|
||||
if supportedEndpoints < len(feature.Endpoints) && len(feature.Endpoints) > 1 {
|
||||
status = "⚠️" // Partial support
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s", status, feature.Name)
|
||||
|
||||
if feature.Essential {
|
||||
fmt.Printf(" ⭐")
|
||||
}
|
||||
|
||||
fmt.Printf("\n")
|
||||
|
||||
if verbose {
|
||||
printVerboseFeatureDetails(feature, supportedEndpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func countSupportedEndpoints(feature models.EndpointFeature, supportedURLs *models.SupportedURLsResponse) int {
|
||||
supportedEndpoints := 0
|
||||
|
||||
for _, endpoint := range feature.Endpoints {
|
||||
if supportedURLs.HasURL(endpoint) {
|
||||
supportedEndpoints++
|
||||
}
|
||||
}
|
||||
|
||||
return supportedEndpoints
|
||||
}
|
||||
|
||||
func printVerboseFeatureDetails(feature models.EndpointFeature, supportedEndpoints int) {
|
||||
fmt.Printf(" %s\n", feature.Description)
|
||||
fmt.Printf(" CLI: %s\n", feature.CLICommand)
|
||||
fmt.Printf(" Endpoints: %d/%d supported", supportedEndpoints, len(feature.Endpoints))
|
||||
|
||||
if supportedEndpoints < len(feature.Endpoints) {
|
||||
fmt.Printf(" (partial)")
|
||||
}
|
||||
|
||||
fmt.Printf("\n")
|
||||
}
|
||||
|
||||
func printMissingEssentialFeatures(supportedURLs *models.SupportedURLsResponse) {
|
||||
missingEssential := supportedURLs.GetMissingEssentialFeatures()
|
||||
if len(missingEssential) > 0 {
|
||||
fmt.Printf("⚠️ Missing Essential Features:\n")
|
||||
|
||||
for _, feature := range missingEssential {
|
||||
fmt.Printf(" ❌ %s - %s\n", feature.Name, feature.Description)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
func printPartiallyImplementedFeatures(supportedURLs *models.SupportedURLsResponse, verbose bool) {
|
||||
partial := supportedURLs.GetPartiallyImplementedFeatures()
|
||||
if len(partial) > 0 && verbose {
|
||||
fmt.Printf("⚠️ Partially Supported Features:\n")
|
||||
|
||||
for _, feature := range partial {
|
||||
fmt.Printf(" 🟡 %s\n", feature.Name)
|
||||
|
||||
for _, endpoint := range feature.Endpoints {
|
||||
status := "❌"
|
||||
if supportedURLs.HasURL(endpoint) {
|
||||
status = "✅"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", status, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printDetailedEndpoints shows the traditional endpoint listing
|
||||
func printDetailedEndpoints(supportedURLs *models.SupportedURLsResponse) {
|
||||
fmt.Printf("📋 Detailed Endpoint Analysis:\n\n")
|
||||
|
||||
// Show core functionality
|
||||
coreURLs := supportedURLs.GetCoreURLs()
|
||||
if len(coreURLs) > 0 {
|
||||
fmt.Printf("🎮 Core Functionality (%d endpoints):\n", len(coreURLs))
|
||||
|
||||
for _, url := range coreURLs {
|
||||
fmt.Printf(" • %s\n", url)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Show streaming functionality
|
||||
streamingURLs := supportedURLs.GetStreamingURLs()
|
||||
if len(streamingURLs) > 0 {
|
||||
fmt.Printf("📻 Streaming Services (%d endpoints):\n", len(streamingURLs))
|
||||
|
||||
for _, url := range streamingURLs {
|
||||
fmt.Printf(" • %s\n", url)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Show advanced audio functionality
|
||||
advancedURLs := supportedURLs.GetAdvancedURLs()
|
||||
if len(advancedURLs) > 0 {
|
||||
fmt.Printf("🔧 Advanced Audio (%d endpoints):\n", len(advancedURLs))
|
||||
|
||||
for _, url := range advancedURLs {
|
||||
fmt.Printf(" • %s\n", url)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Show network functionality
|
||||
networkURLs := supportedURLs.GetNetworkURLs()
|
||||
if len(networkURLs) > 0 {
|
||||
fmt.Printf("🌐 Network & Connectivity (%d endpoints):\n", len(networkURLs))
|
||||
|
||||
for _, url := range networkURLs {
|
||||
fmt.Printf(" • %s\n", url)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Show all supported URLs
|
||||
fmt.Printf("📝 Complete Endpoint List:\n")
|
||||
|
||||
allURLs := supportedURLs.GetURLs()
|
||||
for i, url := range allURLs {
|
||||
fmt.Printf(" %3d. %s\n", i+1, url)
|
||||
}
|
||||
}
|
||||
|
||||
// getDeviceAnalysis handles comprehensive device capability analysis
|
||||
func getDeviceAnalysis(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Analyzing device capabilities", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
supportedURLs, err := client.GetSupportedURLs()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get supported URLs: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printDeviceAnalysis(supportedURLs)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printDeviceAnalysis provides comprehensive device capability analysis
|
||||
func printDeviceAnalysis(supportedURLs *models.SupportedURLsResponse) {
|
||||
fmt.Printf("🔍 Device Capability Analysis:\n")
|
||||
fmt.Printf(" Device ID: %s\n", supportedURLs.DeviceID)
|
||||
|
||||
// Overall score
|
||||
completeness, supported, total := supportedURLs.GetFeatureCompleteness()
|
||||
fmt.Printf(" Feature Coverage: %d%% (%d/%d features)\n", completeness, supported, total)
|
||||
|
||||
// Device classification
|
||||
classification := classifyDevice(supportedURLs)
|
||||
fmt.Printf(" Device Type: %s\n\n", classification)
|
||||
|
||||
// Essential features check
|
||||
missingEssential := supportedURLs.GetMissingEssentialFeatures()
|
||||
if len(missingEssential) > 0 {
|
||||
fmt.Printf("❌ Missing Essential Features:\n")
|
||||
|
||||
for _, feature := range missingEssential {
|
||||
fmt.Printf(" • %s - %s\n", feature.Name, feature.Description)
|
||||
fmt.Printf(" Impact: Device may not function properly without this\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
} else {
|
||||
fmt.Printf("✅ All essential features are supported\n\n")
|
||||
}
|
||||
|
||||
// Show what works
|
||||
supportedFeatures := supportedURLs.GetSupportedFeatures()
|
||||
fmt.Printf("✅ Available Features (%d):\n", len(supportedFeatures))
|
||||
|
||||
categoryCount := make(map[string]int)
|
||||
for _, feature := range supportedFeatures {
|
||||
categoryCount[feature.Category]++
|
||||
}
|
||||
|
||||
for category, count := range categoryCount {
|
||||
emoji := getCategoryEmoji(category)
|
||||
fmt.Printf(" %s %s: %d features\n", emoji, category, count)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// Show what's missing
|
||||
unsupportedFeatures := supportedURLs.GetUnsupportedFeatures()
|
||||
if len(unsupportedFeatures) > 0 {
|
||||
fmt.Printf("❌ Unsupported Features (%d):\n", len(unsupportedFeatures))
|
||||
|
||||
for _, feature := range unsupportedFeatures {
|
||||
fmt.Printf(" • %s - %s\n", feature.Name, feature.Description)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Partial implementations
|
||||
partial := supportedURLs.GetPartiallyImplementedFeatures()
|
||||
if len(partial) > 0 {
|
||||
fmt.Printf("⚠️ Partially Supported Features (%d):\n", len(partial))
|
||||
|
||||
for _, feature := range partial {
|
||||
supportedCount := 0
|
||||
|
||||
for _, endpoint := range feature.Endpoints {
|
||||
if supportedURLs.HasURL(endpoint) {
|
||||
supportedCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" • %s (%d/%d endpoints)\n", feature.Name, supportedCount, len(feature.Endpoints))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
printRecommendations(supportedURLs)
|
||||
|
||||
// CLI usage suggestions
|
||||
printCLIUsageSuggestions(supportedURLs)
|
||||
}
|
||||
|
||||
// classifyDevice determines the device type based on supported features
|
||||
func classifyDevice(supportedURLs *models.SupportedURLsResponse) string {
|
||||
if supportedURLs.HasMultiroomSupport() && supportedURLs.HasAdvancedAudioSupport() {
|
||||
return "Premium SoundTouch Speaker (Full Feature Set)"
|
||||
}
|
||||
|
||||
if supportedURLs.HasMultiroomSupport() {
|
||||
return "Standard SoundTouch Speaker (Multiroom Capable)"
|
||||
}
|
||||
|
||||
if supportedURLs.HasStreamingSupport() && supportedURLs.HasPresetSupport() {
|
||||
return "Basic SoundTouch Speaker"
|
||||
}
|
||||
|
||||
if supportedURLs.HasCorePlaybackSupport() {
|
||||
return "Essential SoundTouch Device"
|
||||
}
|
||||
|
||||
return "Limited SoundTouch Device"
|
||||
}
|
||||
|
||||
// printRecommendations provides usage recommendations based on device capabilities
|
||||
func printRecommendations(supportedURLs *models.SupportedURLsResponse) {
|
||||
fmt.Printf("💡 Recommendations:\n")
|
||||
|
||||
if supportedURLs.HasMultiroomSupport() {
|
||||
fmt.Printf(" 🏠 This device supports multiroom - you can create speaker groups\n")
|
||||
fmt.Printf(" Try: soundtouch-cli zone create --master <this-device> --members <other-devices>\n")
|
||||
}
|
||||
|
||||
if supportedURLs.HasPresetSupport() {
|
||||
fmt.Printf(" ⭐ Save your favorite content as presets for quick access\n")
|
||||
fmt.Printf(" Try: soundtouch-cli preset store-current --slot 1\n")
|
||||
}
|
||||
|
||||
if supportedURLs.HasStreamingSupport() {
|
||||
fmt.Printf(" 📻 Browse and discover new content from streaming services\n")
|
||||
fmt.Printf(" Try: soundtouch-cli browse tunein, station search-tunein --query jazz\n")
|
||||
}
|
||||
|
||||
if supportedURLs.HasAdvancedAudioSupport() {
|
||||
fmt.Printf(" 🔧 Fine-tune your audio with advanced controls\n")
|
||||
fmt.Printf(" Try: soundtouch-cli audio dsp get, audio tone get\n")
|
||||
}
|
||||
|
||||
if !supportedURLs.HasURL("/bassCapabilities") {
|
||||
fmt.Printf(" ⚠️ Device may have limited bass control options\n")
|
||||
}
|
||||
|
||||
if !supportedURLs.HasURL("/balance") {
|
||||
fmt.Printf(" ⚠️ No balance control available on this device\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// printCLIUsageSuggestions shows common CLI commands for this device
|
||||
func printCLIUsageSuggestions(supportedURLs *models.SupportedURLsResponse) {
|
||||
fmt.Printf("🚀 Common Commands for This Device:\n")
|
||||
|
||||
// Always available
|
||||
fmt.Printf(" • Get device info: soundtouch-cli info get\n")
|
||||
fmt.Printf(" • Control volume: soundtouch-cli volume set --level 50\n")
|
||||
|
||||
if supportedURLs.HasURL("/nowPlaying") {
|
||||
fmt.Printf(" • Check what's playing: soundtouch-cli play now\n")
|
||||
}
|
||||
|
||||
if supportedURLs.HasURL("/sources") {
|
||||
fmt.Printf(" • List audio sources: soundtouch-cli source list\n")
|
||||
}
|
||||
|
||||
if supportedURLs.HasURL("/presets") {
|
||||
fmt.Printf(" • Manage presets: soundtouch-cli preset list\n")
|
||||
}
|
||||
|
||||
if supportedURLs.HasURL("/bass") {
|
||||
fmt.Printf(" • Adjust bass: soundtouch-cli bass set --level 5\n")
|
||||
}
|
||||
|
||||
if supportedURLs.HasURL("/setZone") {
|
||||
fmt.Printf(" • Create speaker group: soundtouch-cli zone create\n")
|
||||
}
|
||||
|
||||
if supportedURLs.HasURL("/search") {
|
||||
fmt.Printf(" • Search content: soundtouch-cli station search-tunein --query \"classic rock\"\n")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// getCategoryEmoji returns emoji for feature categories
|
||||
func getCategoryEmoji(category string) string {
|
||||
emojis := map[string]string{
|
||||
"Core": "⚡",
|
||||
"Audio": "🔊",
|
||||
"Playback": "▶️",
|
||||
"Sources": "📱",
|
||||
"Content": "📻",
|
||||
"Presets": "⭐",
|
||||
"Multiroom": "🏠",
|
||||
"Network": "🌐",
|
||||
"System": "⚙️",
|
||||
}
|
||||
if emoji, exists := emojis[category]; exists {
|
||||
return emoji
|
||||
}
|
||||
|
||||
return "📋"
|
||||
}
|
||||
|
||||
// getTrackInfo gets the track information
|
||||
func getTrackInfo(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Getting track information", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Println("⚠️ WARNING: /trackInfo endpoint times out on real devices.")
|
||||
fmt.Println(" Use 'soundtouch-cli now' (playback status) command instead for track information.")
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// introspectService handles getting introspect data for a specific service
|
||||
func introspectService(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source := strings.ToUpper(c.String("source"))
|
||||
sourceAccount := c.String("account")
|
||||
|
||||
// Check service availability first
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable(source, fmt.Sprintf("get introspect data for %s", strings.ToLower(source))) {
|
||||
PrintWarning(fmt.Sprintf("Service %s may not be available, but continuing with introspect request...", source))
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Getting introspect data for %s", source), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if sourceAccount != "" {
|
||||
fmt.Printf("Source Account: %s\n", sourceAccount)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
response, err := client.Introspect(source, sourceAccount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get introspect data: %w", err)
|
||||
}
|
||||
|
||||
// Print basic information
|
||||
fmt.Printf("=== %s Service Introspect Data ===\n", source)
|
||||
printIntrospectBasicInfo(response)
|
||||
|
||||
// Print service state
|
||||
fmt.Printf("\n=== Service State ===\n")
|
||||
printIntrospectServiceState(response)
|
||||
|
||||
// Print capabilities
|
||||
fmt.Printf("\n=== Service Capabilities ===\n")
|
||||
printIntrospectCapabilities(response)
|
||||
|
||||
// Print history information
|
||||
if response.GetMaxHistorySize() > 0 {
|
||||
fmt.Printf("\n=== Content History ===\n")
|
||||
printIntrospectHistory(response)
|
||||
}
|
||||
|
||||
// Print technical details
|
||||
if response.TokenLastChangedTimeSeconds > 0 || response.PlayStatusState != "" {
|
||||
fmt.Printf("\n=== Technical Details ===\n")
|
||||
printIntrospectTechnicalDetails(response)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// introspectSpotify handles getting Spotify introspect data using convenience method
|
||||
func introspectSpotify(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourceAccount := c.String("account")
|
||||
|
||||
// Check Spotify availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.ValidateSpotifyAvailable("get Spotify introspect data") {
|
||||
PrintWarning("Spotify may not be available, but continuing with introspect request...")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Getting Spotify introspect data", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if sourceAccount != "" {
|
||||
fmt.Printf("Spotify Account: %s\n", sourceAccount)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
response, err := client.IntrospectSpotify(sourceAccount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get Spotify introspect data: %w", err)
|
||||
}
|
||||
|
||||
// Print Spotify-specific information
|
||||
fmt.Printf("=== Spotify Service Introspect Data ===\n")
|
||||
printIntrospectBasicInfo(response)
|
||||
|
||||
// Print service state with Spotify context
|
||||
fmt.Printf("\n=== Spotify Service State ===\n")
|
||||
printIntrospectServiceState(response)
|
||||
|
||||
// Print Spotify capabilities
|
||||
fmt.Printf("\n=== Spotify Service Capabilities ===\n")
|
||||
printIntrospectCapabilities(response)
|
||||
|
||||
// Show Spotify-specific recommendations
|
||||
if response.IsInactive() {
|
||||
fmt.Printf("\n💡 Spotify Setup Recommendations:\n")
|
||||
|
||||
if !response.HasUser() {
|
||||
fmt.Printf(" • Sign in to your Spotify account on the device\n")
|
||||
}
|
||||
|
||||
fmt.Printf(" • Use 'soundtouch-cli source select --source SPOTIFY' to activate Spotify\n")
|
||||
fmt.Printf(" • Ensure you have Spotify Premium for full functionality\n")
|
||||
}
|
||||
|
||||
// Print history information
|
||||
if response.GetMaxHistorySize() > 0 {
|
||||
fmt.Printf("\n=== Spotify Content History ===\n")
|
||||
printIntrospectHistory(response)
|
||||
}
|
||||
|
||||
// Print technical details
|
||||
if response.TokenLastChangedTimeSeconds > 0 || response.PlayStatusState != "" {
|
||||
fmt.Printf("\n=== Technical Details ===\n")
|
||||
printIntrospectTechnicalDetails(response)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// introspectAllServices handles getting introspect data for all available services
|
||||
func introspectAllServices(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Getting introspect data for all services", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
// Get service availability to know which services to check
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get service availability: %w", err)
|
||||
}
|
||||
|
||||
// Services to introspect (only streaming services that support introspect)
|
||||
servicesToCheck := []string{"SPOTIFY", "PANDORA", "TUNEIN", "AMAZON", "DEEZER"}
|
||||
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
|
||||
for i, source := range servicesToCheck {
|
||||
if i > 0 {
|
||||
fmt.Println("\n" + strings.Repeat("─", 50))
|
||||
}
|
||||
|
||||
// Check if service is available
|
||||
serviceType := sourceToServiceType(source)
|
||||
if serviceType != "" && !serviceAvailability.IsServiceAvailable(serviceType) {
|
||||
fmt.Printf("\n❌ %s: Service not available on this device\n", source)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("\n🔍 Getting introspect data for %s...\n", source)
|
||||
|
||||
response, err := client.Introspect(source, "")
|
||||
if err != nil {
|
||||
fmt.Printf("❌ %s: Failed to get introspect data - %v\n", source, err)
|
||||
|
||||
failCount++
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("✅ %s: Successfully retrieved introspect data\n", source)
|
||||
printIntrospectSummary(source, response)
|
||||
|
||||
successCount++
|
||||
}
|
||||
|
||||
// Print summary
|
||||
fmt.Print("\n" + strings.Repeat("═", 50) + "\n")
|
||||
fmt.Printf("📊 Introspect Summary:\n")
|
||||
fmt.Printf(" ✅ Successful: %d services\n", successCount)
|
||||
fmt.Printf(" ❌ Failed: %d services\n", failCount)
|
||||
fmt.Printf(" 📡 Total checked: %d services\n", len(servicesToCheck))
|
||||
|
||||
if successCount > 0 {
|
||||
PrintSuccess(fmt.Sprintf("Successfully retrieved introspect data for %d services", successCount))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printIntrospectBasicInfo prints basic introspect information
|
||||
func printIntrospectBasicInfo(response *models.IntrospectResponse) {
|
||||
fmt.Printf("State: %s\n", response.State)
|
||||
|
||||
if response.HasUser() {
|
||||
fmt.Printf("User: %s\n", response.User)
|
||||
}
|
||||
|
||||
fmt.Printf("Currently Playing: %s\n", formatBooleanStatus(response.IsPlaying))
|
||||
|
||||
if response.HasCurrentContent() {
|
||||
fmt.Printf("Current Content: %s\n", response.CurrentURI)
|
||||
}
|
||||
|
||||
fmt.Printf("Shuffle Mode: %s\n", response.ShuffleMode)
|
||||
|
||||
if response.HasSubscription() {
|
||||
fmt.Printf("Subscription Type: %s\n", response.SubscriptionType)
|
||||
}
|
||||
}
|
||||
|
||||
// printIntrospectServiceState prints service state information
|
||||
func printIntrospectServiceState(response *models.IntrospectResponse) {
|
||||
if response.IsActive() {
|
||||
fmt.Printf("✅ Service is ACTIVE\n")
|
||||
} else if response.IsInactive() {
|
||||
fmt.Printf("❌ Service is INACTIVE")
|
||||
|
||||
if response.GetState() == models.IntrospectStateInactiveUnselected {
|
||||
fmt.Printf(" (Never been used)")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Additional state information
|
||||
if response.IsPlaying {
|
||||
fmt.Printf("🎵 Currently playing content\n")
|
||||
} else {
|
||||
fmt.Printf("⏸️ Not currently playing\n")
|
||||
}
|
||||
|
||||
if response.IsShuffleEnabled() {
|
||||
fmt.Printf("🔀 Shuffle mode is ON\n")
|
||||
} else {
|
||||
fmt.Printf("➡️ Shuffle mode is OFF\n")
|
||||
}
|
||||
}
|
||||
|
||||
// printIntrospectCapabilities prints service capabilities
|
||||
func printIntrospectCapabilities(response *models.IntrospectResponse) {
|
||||
capabilities := []struct {
|
||||
supported bool
|
||||
feature string
|
||||
icon string
|
||||
}{
|
||||
{response.SupportsSkipPrevious(), "Skip Previous", "⏮️"},
|
||||
{response.SupportsSeek(), "Seek within tracks", "🎯"},
|
||||
{response.SupportsResume(), "Resume playback", "▶️"},
|
||||
}
|
||||
|
||||
for _, cap := range capabilities {
|
||||
status := "❌"
|
||||
if cap.supported {
|
||||
status = "✅"
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s %s\n", status, cap.icon, cap.feature)
|
||||
}
|
||||
|
||||
// Data collection status
|
||||
if response.CollectsData() {
|
||||
fmt.Printf("📊 Data collection: ENABLED\n")
|
||||
} else {
|
||||
fmt.Printf("🚫 Data collection: DISABLED\n")
|
||||
}
|
||||
}
|
||||
|
||||
// printIntrospectHistory prints content history information
|
||||
func printIntrospectHistory(response *models.IntrospectResponse) {
|
||||
fmt.Printf("Max History Size: %d items\n", response.GetMaxHistorySize())
|
||||
}
|
||||
|
||||
// printIntrospectTechnicalDetails prints technical details
|
||||
func printIntrospectTechnicalDetails(response *models.IntrospectResponse) {
|
||||
if response.TokenLastChangedTimeSeconds > 0 {
|
||||
// Convert timestamp to readable format
|
||||
tokenTime := time.Unix(response.TokenLastChangedTimeSeconds, 0)
|
||||
fmt.Printf("Token Last Changed: %s\n", tokenTime.Format("2006-01-02 15:04:05 MST"))
|
||||
fmt.Printf("Token Timestamp: %d seconds since Unix epoch\n", response.TokenLastChangedTimeSeconds)
|
||||
|
||||
if response.TokenLastChangedTimeMicroseconds > 0 {
|
||||
fmt.Printf("Token Microseconds: %d\n", response.TokenLastChangedTimeMicroseconds)
|
||||
}
|
||||
}
|
||||
|
||||
if response.PlayStatusState != "" {
|
||||
fmt.Printf("Play Status State: %s\n", response.PlayStatusState)
|
||||
}
|
||||
|
||||
fmt.Printf("Received Playback Request: %s\n", formatBooleanStatus(response.ReceivedPlaybackRequest))
|
||||
}
|
||||
|
||||
// printIntrospectSummary prints a brief summary for the "all" command
|
||||
func printIntrospectSummary(_ string, response *models.IntrospectResponse) {
|
||||
fmt.Printf(" State: %s", response.State)
|
||||
|
||||
if response.HasUser() {
|
||||
fmt.Printf(" (User: %s)", response.User)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
fmt.Printf(" Playing: %s", formatBooleanStatus(response.IsPlaying))
|
||||
|
||||
if response.HasCurrentContent() {
|
||||
fmt.Printf(" | Content: %.50s", response.CurrentURI)
|
||||
|
||||
if len(response.CurrentURI) > 50 {
|
||||
fmt.Printf("...")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
var capabilities []string
|
||||
if response.SupportsSkipPrevious() {
|
||||
capabilities = append(capabilities, "Skip")
|
||||
}
|
||||
|
||||
if response.SupportsSeek() {
|
||||
capabilities = append(capabilities, "Seek")
|
||||
}
|
||||
|
||||
if response.SupportsResume() {
|
||||
capabilities = append(capabilities, "Resume")
|
||||
}
|
||||
|
||||
if len(capabilities) > 0 {
|
||||
fmt.Printf(" Capabilities: %s\n", strings.Join(capabilities, ", "))
|
||||
} else {
|
||||
fmt.Printf(" Capabilities: None\n")
|
||||
}
|
||||
}
|
||||
|
||||
// formatBooleanStatus formats boolean values for display
|
||||
func formatBooleanStatus(value bool) string {
|
||||
if value {
|
||||
return "✅ Yes"
|
||||
}
|
||||
|
||||
return "❌ No"
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestIntrospectCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedOutput []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "introspect service with source flag",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY"},
|
||||
expectedOutput: []string{
|
||||
"Getting introspect data for SPOTIFY",
|
||||
"=== SPOTIFY Service Introspect Data ===",
|
||||
"State: Active",
|
||||
"User: test_user",
|
||||
"Currently Playing: ✅ Yes",
|
||||
"Current Content: spotify://track/123",
|
||||
"Shuffle Mode: ON",
|
||||
"Subscription Type: Premium",
|
||||
"=== Service State ===",
|
||||
"✅ Service is ACTIVE",
|
||||
"🎵 Currently playing content",
|
||||
"🔀 Shuffle mode is ON",
|
||||
"=== Service Capabilities ===",
|
||||
"✅ ⏮️ Skip Previous",
|
||||
"✅ 🎯 Seek within tracks",
|
||||
"✅ ▶️ Resume playback",
|
||||
"🚫 Data collection: DISABLED",
|
||||
"=== Spotify Content History ===",
|
||||
"Max History Size: 15 items",
|
||||
"=== Technical Details ===",
|
||||
"Token Last Changed:",
|
||||
"Token Timestamp: 1702566495",
|
||||
"Play Status State: 2",
|
||||
"Received Playback Request: ❌ No",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "introspect spotify convenience command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect-spotify"},
|
||||
expectedOutput: []string{
|
||||
"Getting Spotify introspect data",
|
||||
"=== Spotify Service Introspect Data ===",
|
||||
"State: Active",
|
||||
"User: test_user",
|
||||
"=== Spotify Service State ===",
|
||||
"✅ Service is ACTIVE",
|
||||
"=== Spotify Service Capabilities ===",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "introspect with account parameter",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect", "--source", "SPOTIFY", "--account", "my_spotify_account"},
|
||||
expectedOutput: []string{
|
||||
"Getting introspect data for SPOTIFY",
|
||||
"Source Account: my_spotify_account",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "introspect missing source flag",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "source", "introspect"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "introspect missing host",
|
||||
args: []string{"soundtouch-cli", "source", "introspect", "--source", "SPOTIFY"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Skip actual execution for now - these would need mock HTTP servers
|
||||
// This test structure shows how the CLI commands would be tested
|
||||
t.Skip("Integration test - requires mock HTTP server setup")
|
||||
|
||||
// Example of how you would set up the test:
|
||||
// app := createTestApp()
|
||||
//
|
||||
// var buf bytes.Buffer
|
||||
// app.Writer = &buf
|
||||
// app.ErrWriter = &buf
|
||||
//
|
||||
// err := app.Run(tt.args)
|
||||
//
|
||||
// if tt.expectError {
|
||||
// if err == nil {
|
||||
// t.Error("expected error, got nil")
|
||||
// }
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// if err != nil {
|
||||
// t.Fatalf("unexpected error: %v", err)
|
||||
// }
|
||||
//
|
||||
// output := buf.String()
|
||||
// for _, expected := range tt.expectedOutput {
|
||||
// if !strings.Contains(output, expected) {
|
||||
// t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
// }
|
||||
// }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintIntrospectBasicInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response *models.IntrospectResponse
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "active spotify response",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "Active",
|
||||
User: "test_user",
|
||||
IsPlaying: true,
|
||||
ShuffleMode: "ON",
|
||||
CurrentURI: "spotify://track/123",
|
||||
SubscriptionType: "Premium",
|
||||
},
|
||||
expected: []string{
|
||||
"State: Active",
|
||||
"User: test_user",
|
||||
"Currently Playing: ✅ Yes",
|
||||
"Current Content: spotify://track/123",
|
||||
"Shuffle Mode: ON",
|
||||
"Subscription Type: Premium",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inactive response",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "InactiveUnselected",
|
||||
User: "",
|
||||
IsPlaying: false,
|
||||
ShuffleMode: "OFF",
|
||||
CurrentURI: "",
|
||||
},
|
||||
expected: []string{
|
||||
"State: InactiveUnselected",
|
||||
"Currently Playing: ❌ No",
|
||||
"Shuffle Mode: OFF",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stdout
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Call the function
|
||||
printIntrospectBasicInfo(tt.response)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := buf.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check expected strings are present
|
||||
for _, expected := range tt.expected {
|
||||
if !containsSubstring(output, expected) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
|
||||
// Check unwanted strings are not present
|
||||
if tt.response.User == "" && containsSubstring(output, "User:") {
|
||||
t.Error("expected no user information when user is empty")
|
||||
}
|
||||
|
||||
if tt.response.CurrentURI == "" && containsSubstring(output, "Current Content:") {
|
||||
t.Error("expected no current content when URI is empty")
|
||||
}
|
||||
|
||||
if tt.response.SubscriptionType == "" && containsSubstring(output, "Subscription Type:") {
|
||||
t.Error("expected no subscription information when type is empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintIntrospectServiceState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response *models.IntrospectResponse
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "active playing with shuffle",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "Active",
|
||||
IsPlaying: true,
|
||||
ShuffleMode: "ON",
|
||||
},
|
||||
expected: []string{
|
||||
"✅ Service is ACTIVE",
|
||||
"🎵 Currently playing content",
|
||||
"🔀 Shuffle mode is ON",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inactive unselected",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "InactiveUnselected",
|
||||
IsPlaying: false,
|
||||
ShuffleMode: "OFF",
|
||||
},
|
||||
expected: []string{
|
||||
"❌ Service is INACTIVE (Never been used)",
|
||||
"⏸️ Not currently playing",
|
||||
"➡️ Shuffle mode is OFF",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inactive but configured",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "Inactive",
|
||||
IsPlaying: false,
|
||||
ShuffleMode: "OFF",
|
||||
},
|
||||
expected: []string{
|
||||
"❌ Service is INACTIVE",
|
||||
"⏸️ Not currently playing",
|
||||
"➡️ Shuffle mode is OFF",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stdout
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Call the function
|
||||
printIntrospectServiceState(tt.response)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := buf.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check expected strings are present
|
||||
for _, expected := range tt.expected {
|
||||
if !containsSubstring(output, expected) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintIntrospectCapabilities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response *models.IntrospectResponse
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "full capabilities enabled",
|
||||
response: &models.IntrospectResponse{
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: true,
|
||||
SeekSupported: true,
|
||||
ResumeSupported: true,
|
||||
CollectData: true,
|
||||
},
|
||||
},
|
||||
expected: []string{
|
||||
"✅ ⏮️ Skip Previous",
|
||||
"✅ 🎯 Seek within tracks",
|
||||
"✅ ▶️ Resume playback",
|
||||
"📊 Data collection: ENABLED",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "limited capabilities",
|
||||
response: &models.IntrospectResponse{
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: false,
|
||||
SeekSupported: false,
|
||||
ResumeSupported: true,
|
||||
CollectData: false,
|
||||
},
|
||||
},
|
||||
expected: []string{
|
||||
"❌ ⏮️ Skip Previous",
|
||||
"❌ 🎯 Seek within tracks",
|
||||
"✅ ▶️ Resume playback",
|
||||
"🚫 Data collection: DISABLED",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no capabilities info",
|
||||
response: &models.IntrospectResponse{
|
||||
NowPlaying: nil,
|
||||
},
|
||||
expected: []string{
|
||||
"❌ ⏮️ Skip Previous",
|
||||
"❌ 🎯 Seek within tracks",
|
||||
"❌ ▶️ Resume playback",
|
||||
"🚫 Data collection: DISABLED",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stdout
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Call the function
|
||||
printIntrospectCapabilities(tt.response)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := buf.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check expected strings are present
|
||||
for _, expected := range tt.expected {
|
||||
if !containsSubstring(output, expected) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintIntrospectSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
response *models.IntrospectResponse
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "full spotify summary",
|
||||
source: "SPOTIFY",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "Active",
|
||||
User: "spotify_user",
|
||||
IsPlaying: true,
|
||||
CurrentURI: "spotify://track/very_long_track_uri_that_should_be_truncated_because_its_too_long_for_display",
|
||||
NowPlaying: &models.IntrospectNowPlaying{
|
||||
SkipPreviousSupported: true,
|
||||
SeekSupported: true,
|
||||
ResumeSupported: true,
|
||||
},
|
||||
},
|
||||
expected: []string{
|
||||
"State: Active (User: spotify_user)",
|
||||
"Playing: ✅ Yes | Content: spotify://track/very_long_track_uri_that_should_be...",
|
||||
"Capabilities: Skip, Seek, Resume",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal summary",
|
||||
source: "PANDORA",
|
||||
response: &models.IntrospectResponse{
|
||||
State: "Inactive",
|
||||
IsPlaying: false,
|
||||
},
|
||||
expected: []string{
|
||||
"State: Inactive",
|
||||
"Playing: ❌ No",
|
||||
"Capabilities: None",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stdout
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Call the function
|
||||
printIntrospectSummary(tt.source, tt.response)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := buf.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check expected strings are present
|
||||
for _, expected := range tt.expected {
|
||||
if !containsSubstring(output, expected) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatBooleanStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value bool
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "true value",
|
||||
value: true,
|
||||
expected: "✅ Yes",
|
||||
},
|
||||
{
|
||||
name: "false value",
|
||||
value: false,
|
||||
expected: "❌ No",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := formatBooleanStatus(tt.value)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if output contains a substring
|
||||
func containsSubstring(output, substring string) bool {
|
||||
return bytes.Contains([]byte(output), []byte(substring))
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// browseContent handles browsing content sources
|
||||
func browseContent(c *cli.Context) error {
|
||||
source := c.String("source")
|
||||
sourceAccount := c.String("source-account")
|
||||
startItem := c.Int("start")
|
||||
numItems := c.Int("limit")
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Browsing %s content", source), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := client.Navigate(source, sourceAccount, startItem, numItems)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to browse content: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printNavigationResults(response, "Content")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// browseWithMenu handles browsing with menu navigation
|
||||
func browseWithMenu(c *cli.Context) error {
|
||||
source := c.String("source")
|
||||
sourceAccount := c.String("source-account")
|
||||
menu := c.String("menu")
|
||||
sort := c.String("sort")
|
||||
startItem := c.Int("start")
|
||||
numItems := c.Int("limit")
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Browsing %s menu: %s", source, menu), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := client.NavigateWithMenu(source, sourceAccount, menu, sort, startItem, numItems)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to browse menu: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printNavigationResults(response, "Menu Items")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// browseContainer handles browsing into containers/directories
|
||||
func browseContainer(c *cli.Context) error {
|
||||
source := c.String("source")
|
||||
sourceAccount := c.String("source-account")
|
||||
location := c.String("location")
|
||||
itemType := c.String("type")
|
||||
startItem := c.Int("start")
|
||||
numItems := c.Int("limit")
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Browsing %s container: %s", source, location), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Create container content item
|
||||
containerItem := &models.ContentItem{
|
||||
Source: source,
|
||||
Location: location,
|
||||
Type: itemType,
|
||||
}
|
||||
|
||||
response, err := client.NavigateContainer(source, sourceAccount, startItem, numItems, containerItem)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to browse container: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printNavigationResults(response, "Container Contents")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// browseTuneIn handles browsing TuneIn content
|
||||
func browseTuneIn(c *cli.Context) error {
|
||||
sourceAccount := c.String("source-account")
|
||||
startItem := c.Int("start")
|
||||
numItems := c.Int("limit")
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Browsing TuneIn stations", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := client.GetTuneInStations(sourceAccount)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get TuneIn stations: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply pagination if different from defaults
|
||||
if startItem != 1 || numItems != 100 {
|
||||
response, err = client.Navigate("TUNEIN", sourceAccount, startItem, numItems)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to browse TuneIn with pagination: %v", err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
printNavigationResults(response, "TuneIn Stations")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// browsePandora handles browsing Pandora content
|
||||
func browsePandora(c *cli.Context) error {
|
||||
sourceAccount := c.String("source-account")
|
||||
|
||||
if sourceAccount == "" {
|
||||
PrintError("Pandora source account is required")
|
||||
return fmt.Errorf("source account required for Pandora")
|
||||
}
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Browsing Pandora stations", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := client.GetPandoraStations(sourceAccount)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get Pandora stations: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printNavigationResults(response, "Pandora Stations")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// browseStoredMusic handles browsing local/stored music
|
||||
func browseStoredMusic(c *cli.Context) error {
|
||||
sourceAccount := c.String("source-account")
|
||||
|
||||
if sourceAccount == "" {
|
||||
PrintError("Source account (device ID) is required for stored music")
|
||||
return fmt.Errorf("source account required for stored music")
|
||||
}
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Browsing stored music library", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := client.GetStoredMusicLibrary(sourceAccount)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get stored music library: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printNavigationResults(response, "Stored Music Library")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printNavigationResults formats and displays navigation results
|
||||
func printNavigationResults(response *models.NavigateResponse, title string) {
|
||||
fmt.Printf("%s:\n", title)
|
||||
|
||||
if response.TotalItems == 0 {
|
||||
fmt.Printf(" No items found\n")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Total items: %d\n", response.TotalItems)
|
||||
|
||||
if len(response.Items) == 0 {
|
||||
fmt.Printf(" No items in current page\n")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Items:\n")
|
||||
|
||||
for i, item := range response.Items {
|
||||
printNavigationItem(item, i+1, response.Source)
|
||||
}
|
||||
|
||||
printNavigationHints(response)
|
||||
}
|
||||
|
||||
// printNavigationItem prints a single navigation item with its metadata
|
||||
func printNavigationItem(item models.NavigateItem, index int, responseSource string) {
|
||||
fmt.Printf(" %d. %s\n", index, item.GetDisplayName())
|
||||
|
||||
printContentItemInfo(item, responseSource)
|
||||
printItemMetadata(item)
|
||||
printItemType(item)
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// printContentItemInfo prints content item information (source, type, location)
|
||||
func printContentItemInfo(item models.NavigateItem, responseSource string) {
|
||||
if item.ContentItem == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if item.ContentItem.Source != "" && item.ContentItem.Source != responseSource {
|
||||
fmt.Printf(" Source: %s\n", item.ContentItem.Source)
|
||||
}
|
||||
|
||||
if item.Type != "" {
|
||||
fmt.Printf(" Type: %s\n", item.Type)
|
||||
}
|
||||
|
||||
if item.ContentItem.Location != "" && len(item.ContentItem.Location) < 100 {
|
||||
fmt.Printf(" Location: %s\n", item.ContentItem.Location)
|
||||
}
|
||||
}
|
||||
|
||||
// printItemMetadata prints additional metadata (artist, album)
|
||||
func printItemMetadata(item models.NavigateItem) {
|
||||
if item.ArtistName != "" {
|
||||
fmt.Printf(" Artist: %s\n", item.ArtistName)
|
||||
}
|
||||
|
||||
if item.AlbumName != "" {
|
||||
fmt.Printf(" Album: %s\n", item.AlbumName)
|
||||
}
|
||||
}
|
||||
|
||||
// printItemType prints whether the item is a directory or playable
|
||||
func printItemType(item models.NavigateItem) {
|
||||
if item.IsDirectory() {
|
||||
fmt.Printf(" 📁 Directory (can browse into)\n")
|
||||
} else if item.IsPlayable() {
|
||||
fmt.Printf(" ▶️ Playable content\n")
|
||||
}
|
||||
}
|
||||
|
||||
// printNavigationHints prints helpful navigation hints
|
||||
func printNavigationHints(response *models.NavigateResponse) {
|
||||
directories := response.GetDirectories()
|
||||
if len(directories) > 0 {
|
||||
fmt.Printf(" 💡 To browse into a directory, use: browse container --location <location> --type <type>\n")
|
||||
}
|
||||
|
||||
playableItems := response.GetPlayableItems()
|
||||
if len(playableItems) > 0 {
|
||||
fmt.Printf(" 💡 Found %d playable items\n", len(playableItems))
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,47 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func printNetworkInterface(i int, iface *models.NetworkInterface) {
|
||||
fmt.Printf("\n Interface %d:\n", i+1)
|
||||
fmt.Printf(" Type: %s\n", iface.GetType())
|
||||
|
||||
if iface.GetName() != "" {
|
||||
fmt.Printf(" Name: %s\n", iface.GetName())
|
||||
}
|
||||
|
||||
if iface.GetIPAddress() != "" {
|
||||
fmt.Printf(" IP Address: %s\n", iface.GetIPAddress())
|
||||
}
|
||||
|
||||
if iface.GetMacAddress() != "" {
|
||||
fmt.Printf(" MAC Address: %s\n", iface.GetMacAddress())
|
||||
}
|
||||
|
||||
fmt.Printf(" State: %s\n", iface.GetStateDescription())
|
||||
|
||||
if iface.IsWiFi() {
|
||||
if iface.GetSSID() != "" {
|
||||
fmt.Printf(" SSID: %s\n", iface.GetSSID())
|
||||
}
|
||||
|
||||
if iface.GetSignal() != "" {
|
||||
fmt.Printf(" Signal: %s (%d%%)\n", iface.GetSignalDescription(), iface.GetSignalQuality())
|
||||
}
|
||||
|
||||
if iface.GetFrequencyKHz() > 0 {
|
||||
fmt.Printf(" Frequency: %s (%s)\n", iface.FormatFrequency(), iface.GetFrequencyBand())
|
||||
}
|
||||
|
||||
if iface.GetMode() != "" {
|
||||
fmt.Printf(" Mode: %s\n", iface.GetModeDescription())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getNetworkInfo retrieves network information from the device
|
||||
func getNetworkInfo(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
@@ -38,41 +76,7 @@ func getNetworkInfo(c *cli.Context) error {
|
||||
fmt.Printf(" Interfaces (%d):\n", len(interfaces))
|
||||
|
||||
for i := range interfaces {
|
||||
iface := &interfaces[i]
|
||||
fmt.Printf("\n Interface %d:\n", i+1)
|
||||
fmt.Printf(" Type: %s\n", iface.GetType())
|
||||
|
||||
if iface.GetName() != "" {
|
||||
fmt.Printf(" Name: %s\n", iface.GetName())
|
||||
}
|
||||
|
||||
if iface.GetIPAddress() != "" {
|
||||
fmt.Printf(" IP Address: %s\n", iface.GetIPAddress())
|
||||
}
|
||||
|
||||
if iface.GetMacAddress() != "" {
|
||||
fmt.Printf(" MAC Address: %s\n", iface.GetMacAddress())
|
||||
}
|
||||
|
||||
fmt.Printf(" State: %s\n", iface.GetStateDescription())
|
||||
|
||||
if iface.IsWiFi() {
|
||||
if iface.GetSSID() != "" {
|
||||
fmt.Printf(" SSID: %s\n", iface.GetSSID())
|
||||
}
|
||||
|
||||
if iface.GetSignal() != "" {
|
||||
fmt.Printf(" Signal: %s (%d%%)\n", iface.GetSignalDescription(), iface.GetSignalQuality())
|
||||
}
|
||||
|
||||
if iface.GetFrequencyKHz() > 0 {
|
||||
fmt.Printf(" Frequency: %s (%s)\n", iface.FormatFrequency(), iface.GetFrequencyBand())
|
||||
}
|
||||
|
||||
if iface.GetMode() != "" {
|
||||
fmt.Printf(" Mode: %s\n", iface.GetModeDescription())
|
||||
}
|
||||
}
|
||||
printNetworkInterface(i, &interfaces[i])
|
||||
}
|
||||
|
||||
// Show active connections summary
|
||||
|
||||
@@ -32,9 +32,29 @@ func getNowPlaying(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf(" Source: %s\n", nowPlaying.Source)
|
||||
fmt.Printf(" Status: %s\n", nowPlaying.PlayStatus.String())
|
||||
printBasicPlaybackInfo(nowPlaying)
|
||||
printTrackInfo(nowPlaying)
|
||||
printTimeInfo(nowPlaying)
|
||||
printStreamInfo(nowPlaying)
|
||||
printContentDetails(nowPlaying, c.Bool("verbose"))
|
||||
printPlaybackStatus(nowPlaying)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printBasicPlaybackInfo prints basic source and status information
|
||||
func printBasicPlaybackInfo(nowPlaying *models.NowPlaying) {
|
||||
fmt.Printf(" Source: %s\n", nowPlaying.Source)
|
||||
|
||||
if nowPlaying.SourceAccount != "" {
|
||||
fmt.Printf(" Source Account: %s\n", nowPlaying.SourceAccount)
|
||||
}
|
||||
|
||||
fmt.Printf(" Status: %s\n", nowPlaying.PlayStatus.String())
|
||||
}
|
||||
|
||||
// printTrackInfo prints track, artist, and album information
|
||||
func printTrackInfo(nowPlaying *models.NowPlaying) {
|
||||
if nowPlaying.Track != "" {
|
||||
fmt.Printf(" Track: %s\n", nowPlaying.Track)
|
||||
}
|
||||
@@ -46,24 +66,116 @@ func getNowPlaying(c *cli.Context) error {
|
||||
if nowPlaying.Album != "" {
|
||||
fmt.Printf(" Album: %s\n", nowPlaying.Album)
|
||||
}
|
||||
}
|
||||
|
||||
if nowPlaying.HasTimeInfo() {
|
||||
fmt.Printf(" Duration: %s\n", nowPlaying.FormatDuration())
|
||||
|
||||
if nowPlaying.Position != nil {
|
||||
fmt.Printf(" Position: %s\n", nowPlaying.FormatPosition())
|
||||
}
|
||||
// printTimeInfo prints duration and position information
|
||||
func printTimeInfo(nowPlaying *models.NowPlaying) {
|
||||
if !nowPlaying.HasTimeInfo() {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Duration: %s\n", nowPlaying.FormatDuration())
|
||||
|
||||
if nowPlaying.Position != nil {
|
||||
fmt.Printf(" Position: %s\n", nowPlaying.FormatPosition())
|
||||
}
|
||||
}
|
||||
|
||||
// printStreamInfo prints stream type information
|
||||
func printStreamInfo(nowPlaying *models.NowPlaying) {
|
||||
if nowPlaying.StreamType != "" {
|
||||
fmt.Printf(" Stream Type: %s\n", nowPlaying.StreamType)
|
||||
}
|
||||
}
|
||||
|
||||
if nowPlaying.PlayStatus == models.PlayStatusBuffering {
|
||||
fmt.Printf(" Note: Content is buffering\n")
|
||||
// printContentDetails prints detailed content information when verbose or location is available
|
||||
func printContentDetails(nowPlaying *models.NowPlaying, verbose bool) {
|
||||
if nowPlaying.ContentItem == nil {
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
showDetails := verbose || nowPlaying.ContentItem.Location != ""
|
||||
if !showDetails {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\nContent Details:\n")
|
||||
printContentLocation(nowPlaying.ContentItem)
|
||||
printVerboseContentInfo(nowPlaying, verbose)
|
||||
|
||||
if verbose {
|
||||
printVerbosePlaybackDetails(nowPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
// printContentLocation prints the content location
|
||||
func printContentLocation(contentItem *models.ContentItem) {
|
||||
if contentItem.Location != "" {
|
||||
fmt.Printf(" Location: %s\n", contentItem.Location)
|
||||
}
|
||||
}
|
||||
|
||||
// printVerboseContentInfo prints verbose content information
|
||||
func printVerboseContentInfo(nowPlaying *models.NowPlaying, verbose bool) {
|
||||
if !verbose || nowPlaying.ContentItem == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if nowPlaying.ContentItem.Type != "" {
|
||||
fmt.Printf(" Content Type: %s\n", nowPlaying.ContentItem.Type)
|
||||
}
|
||||
|
||||
if nowPlaying.ContentItem.ItemName != "" && nowPlaying.ContentItem.ItemName != nowPlaying.Track {
|
||||
fmt.Printf(" Item Name: %s\n", nowPlaying.ContentItem.ItemName)
|
||||
}
|
||||
|
||||
if nowPlaying.ContentItem.ContainerArt != "" {
|
||||
fmt.Printf(" Container Art: %s\n", nowPlaying.ContentItem.ContainerArt)
|
||||
}
|
||||
|
||||
fmt.Printf(" Presetable: %t\n", nowPlaying.ContentItem.IsPresetable)
|
||||
}
|
||||
|
||||
// printVerbosePlaybackDetails prints detailed playback information in verbose mode
|
||||
func printVerbosePlaybackDetails(nowPlaying *models.NowPlaying) {
|
||||
fmt.Printf("\nPlayback Details:\n")
|
||||
|
||||
// Shuffle and repeat settings
|
||||
if nowPlaying.ShuffleSetting != "" {
|
||||
fmt.Printf(" Shuffle: %s\n", nowPlaying.ShuffleSetting.String())
|
||||
}
|
||||
|
||||
if nowPlaying.RepeatSetting != "" {
|
||||
fmt.Printf(" Repeat: %s\n", nowPlaying.RepeatSetting.String())
|
||||
}
|
||||
|
||||
// Track ID
|
||||
if nowPlaying.TrackID != "" {
|
||||
fmt.Printf(" Track ID: %s\n", nowPlaying.TrackID)
|
||||
}
|
||||
|
||||
// Art details
|
||||
if nowPlaying.Art != nil {
|
||||
fmt.Printf(" Art Image Status: %s\n", nowPlaying.Art.ArtImageStatus)
|
||||
|
||||
if nowPlaying.Art.URL != "" {
|
||||
fmt.Printf(" Art URL: %s\n", nowPlaying.Art.URL)
|
||||
}
|
||||
}
|
||||
|
||||
// Capabilities
|
||||
fmt.Printf("\nCapabilities:\n")
|
||||
fmt.Printf(" Skip Enabled: %t\n", nowPlaying.CanSkip())
|
||||
fmt.Printf(" Skip Previous Enabled: %t\n", nowPlaying.CanSkipPrevious())
|
||||
fmt.Printf(" Favorite Enabled: %t\n", nowPlaying.CanFavorite())
|
||||
fmt.Printf(" Seek Supported: %t\n", nowPlaying.IsSeekSupported())
|
||||
}
|
||||
|
||||
// printPlaybackStatus prints special status messages
|
||||
func printPlaybackStatus(nowPlaying *models.NowPlaying) {
|
||||
if nowPlaying.PlayStatus == models.PlayStatusBuffering {
|
||||
fmt.Printf("\nNote: Content is buffering\n")
|
||||
}
|
||||
}
|
||||
|
||||
// playCommand handles play command
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestShouldShowContentDetails(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
verbose bool
|
||||
contentItem *models.ContentItem
|
||||
expected bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "verbose_flag_true_shows_details",
|
||||
verbose: true,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Location: "",
|
||||
},
|
||||
expected: true,
|
||||
description: "Verbose flag should always show details regardless of location",
|
||||
},
|
||||
{
|
||||
name: "spotify_with_location_shows_details",
|
||||
verbose: false,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Location: "spotify:track:123456789",
|
||||
},
|
||||
expected: true,
|
||||
description: "Any source with location should show details",
|
||||
},
|
||||
{
|
||||
name: "tunein_with_location_shows_details",
|
||||
verbose: false,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Location: "/v1/playback/station/s33828",
|
||||
},
|
||||
expected: true,
|
||||
description: "TUNEIN with location should show details",
|
||||
},
|
||||
{
|
||||
name: "local_internet_radio_with_location_shows_details",
|
||||
verbose: false,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Location: "https://stream.example.com/radio",
|
||||
},
|
||||
expected: true,
|
||||
description: "Local internet radio with location should show details",
|
||||
},
|
||||
{
|
||||
name: "stored_music_with_location_shows_details",
|
||||
verbose: false,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "STORED_MUSIC",
|
||||
Location: "6_a2874b5d_4f83d999",
|
||||
},
|
||||
expected: true,
|
||||
description: "Stored music with location should show details",
|
||||
},
|
||||
{
|
||||
name: "pandora_with_location_shows_details",
|
||||
verbose: false,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "PANDORA",
|
||||
Location: "126740707481236361",
|
||||
},
|
||||
expected: true,
|
||||
description: "Pandora with location should show details",
|
||||
},
|
||||
{
|
||||
name: "local_music_with_location_shows_details",
|
||||
verbose: false,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "LOCAL_MUSIC",
|
||||
Location: "album:983",
|
||||
},
|
||||
expected: true,
|
||||
description: "Local music with location should show details",
|
||||
},
|
||||
{
|
||||
name: "no_location_no_verbose_hides_details",
|
||||
verbose: false,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "BLUETOOTH",
|
||||
Location: "",
|
||||
},
|
||||
expected: false,
|
||||
description: "No location and no verbose should hide details",
|
||||
},
|
||||
{
|
||||
name: "empty_location_no_verbose_hides_details",
|
||||
verbose: false,
|
||||
contentItem: &models.ContentItem{
|
||||
Source: "AIRPLAY",
|
||||
Location: "",
|
||||
},
|
||||
expected: false,
|
||||
description: "Empty location and no verbose should hide details",
|
||||
},
|
||||
{
|
||||
name: "nil_content_item_hides_details",
|
||||
verbose: false,
|
||||
contentItem: nil,
|
||||
expected: false,
|
||||
description: "Nil content item should hide details",
|
||||
},
|
||||
{
|
||||
name: "verbose_with_nil_content_item_hides_details",
|
||||
verbose: true,
|
||||
contentItem: nil,
|
||||
expected: false,
|
||||
description: "Even verbose flag cannot show details for nil content item",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// This mimics the logic from getNowPlaying function:
|
||||
// showDetails := verbose || (nowPlaying.ContentItem != nil && nowPlaying.ContentItem.Location != "")
|
||||
result := shouldShowContentDetails(tt.verbose, tt.contentItem)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("shouldShowContentDetails(%v, %+v) = %v, want %v. %s",
|
||||
tt.verbose, tt.contentItem, result, tt.expected, tt.description)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestContentDetailsDisplayLogic(t *testing.T) {
|
||||
// Test the specific conditions that determine when to show content details
|
||||
tests := []struct {
|
||||
name string
|
||||
verbose bool
|
||||
hasContentItem bool
|
||||
hasLocation bool
|
||||
expectedShow bool
|
||||
}{
|
||||
{"verbose_true_overrides_all", true, false, false, false}, // Note: still need contentItem != nil
|
||||
{"verbose_false_with_location", false, true, true, true},
|
||||
{"verbose_false_without_location", false, true, false, false},
|
||||
{"verbose_false_without_contentitem", false, false, false, false},
|
||||
{"verbose_true_with_contentitem_and_location", true, true, true, true},
|
||||
{"verbose_true_with_contentitem_no_location", true, true, false, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var contentItem *models.ContentItem
|
||||
if tt.hasContentItem {
|
||||
contentItem = &models.ContentItem{
|
||||
Source: "TEST_SOURCE",
|
||||
}
|
||||
if tt.hasLocation {
|
||||
contentItem.Location = "test_location"
|
||||
}
|
||||
}
|
||||
|
||||
result := shouldShowContentDetails(tt.verbose, contentItem)
|
||||
if result != tt.expectedShow {
|
||||
t.Errorf("Expected %v, got %v for verbose=%v, hasContentItem=%v, hasLocation=%v",
|
||||
tt.expectedShow, result, tt.verbose, tt.hasContentItem, tt.hasLocation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerboseFlagSpecificFields(t *testing.T) {
|
||||
// Test which fields should only be shown in verbose mode
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "uri",
|
||||
Location: "spotify:track:123456789",
|
||||
SourceAccount: "testuser",
|
||||
IsPresetable: true,
|
||||
ItemName: "Test Track",
|
||||
ContainerArt: "https://example.com/art.jpg",
|
||||
}
|
||||
|
||||
// These fields should always be shown when content details are displayed
|
||||
alwaysShown := []string{"Location"}
|
||||
|
||||
// These fields should only be shown in verbose mode
|
||||
verboseOnly := []string{"Type", "ItemName", "IsPresetable"}
|
||||
|
||||
t.Run("verbose_mode_shows_all_fields", func(t *testing.T) {
|
||||
verbose := true
|
||||
showDetails := shouldShowContentDetails(verbose, contentItem)
|
||||
|
||||
if !showDetails {
|
||||
t.Error("Expected to show details in verbose mode")
|
||||
}
|
||||
|
||||
// In verbose mode, we would show all fields
|
||||
// (This is testing the conceptual logic, actual field display is in the CLI function)
|
||||
})
|
||||
|
||||
t.Run("non_verbose_mode_shows_limited_fields", func(t *testing.T) {
|
||||
verbose := false
|
||||
showDetails := shouldShowContentDetails(verbose, contentItem)
|
||||
|
||||
if !showDetails {
|
||||
t.Error("Expected to show details when location is present")
|
||||
}
|
||||
|
||||
// In non-verbose mode, we would only show location
|
||||
// The actual field filtering happens in the CLI display logic
|
||||
_ = alwaysShown // Would show these
|
||||
_ = verboseOnly // Would NOT show these
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function that encapsulates the logic from getNowPlaying
|
||||
func shouldShowContentDetails(verbose bool, contentItem *models.ContentItem) bool {
|
||||
// This mirrors the exact logic from cmd_playback.go:
|
||||
// showDetails := verbose || (nowPlaying.ContentItem != nil && nowPlaying.ContentItem.Location != "")
|
||||
// if showDetails && nowPlaying.ContentItem != nil { ... }
|
||||
hasLocationData := contentItem != nil && contentItem.Location != ""
|
||||
showDetails := verbose || hasLocationData
|
||||
|
||||
return showDetails && contentItem != nil
|
||||
}
|
||||
|
||||
func TestRealWorldScenarios(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
source string
|
||||
location string
|
||||
verbose bool
|
||||
expected bool
|
||||
useCase string
|
||||
}{
|
||||
{
|
||||
name: "spotify_user_wants_uri",
|
||||
source: "SPOTIFY",
|
||||
location: "spotify:playlist:37i9dQZF1DX0XUsuxWHRQd",
|
||||
verbose: false,
|
||||
expected: true,
|
||||
useCase: "User playing Spotify wants to see URI for storePreset",
|
||||
},
|
||||
{
|
||||
name: "radio_user_wants_station_id",
|
||||
source: "TUNEIN",
|
||||
location: "/v1/playback/station/s33828",
|
||||
verbose: false,
|
||||
expected: true,
|
||||
useCase: "User playing radio wants to see station ID for storePreset",
|
||||
},
|
||||
{
|
||||
name: "bluetooth_no_useful_location",
|
||||
source: "BLUETOOTH",
|
||||
location: "",
|
||||
verbose: false,
|
||||
expected: false,
|
||||
useCase: "Bluetooth has no useful location data for presets",
|
||||
},
|
||||
{
|
||||
name: "developer_debugging_verbose",
|
||||
source: "AIRPLAY",
|
||||
location: "",
|
||||
verbose: true,
|
||||
expected: true,
|
||||
useCase: "Developer wants all available info regardless of source",
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
contentItem := &models.ContentItem{
|
||||
Source: scenario.source,
|
||||
Location: scenario.location,
|
||||
}
|
||||
|
||||
result := shouldShowContentDetails(scenario.verbose, contentItem)
|
||||
if result != scenario.expected {
|
||||
t.Errorf("Scenario '%s' failed: %s. Expected %v, got %v",
|
||||
scenario.name, scenario.useCase, scenario.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// storeCurrentPreset handles storing currently playing content as preset
|
||||
func storeCurrentPreset(c *cli.Context) error {
|
||||
slot := c.Int("slot")
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Storing current content as preset %d", slot), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check what's currently playing
|
||||
nowPlaying, err := client.GetNowPlaying()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get current content: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if nowPlaying.IsEmpty() {
|
||||
PrintError("No content currently playing")
|
||||
return fmt.Errorf("no content currently playing")
|
||||
}
|
||||
|
||||
if nowPlaying.ContentItem == nil {
|
||||
PrintError("Current content has no preset information")
|
||||
return fmt.Errorf("current content cannot be saved as preset")
|
||||
}
|
||||
|
||||
if !nowPlaying.ContentItem.IsPresetable {
|
||||
PrintError("Current content cannot be saved as preset")
|
||||
fmt.Printf(" Content: %s\n", nowPlaying.Track)
|
||||
fmt.Printf(" Source: %s\n", nowPlaying.Source)
|
||||
|
||||
return fmt.Errorf("current content cannot be preset")
|
||||
}
|
||||
|
||||
// Show what we're about to store
|
||||
fmt.Printf("Current Content:\n")
|
||||
fmt.Printf(" Track: %s\n", nowPlaying.Track)
|
||||
|
||||
if nowPlaying.Artist != "" {
|
||||
fmt.Printf(" Artist: %s\n", nowPlaying.Artist)
|
||||
}
|
||||
|
||||
if nowPlaying.Album != "" {
|
||||
fmt.Printf(" Album: %s\n", nowPlaying.Album)
|
||||
}
|
||||
|
||||
fmt.Printf(" Source: %s\n", nowPlaying.Source)
|
||||
|
||||
if nowPlaying.ContentItem.Location != "" {
|
||||
fmt.Printf(" Location: %s\n", nowPlaying.ContentItem.Location)
|
||||
}
|
||||
|
||||
// Store as preset
|
||||
err = client.StoreCurrentAsPreset(slot)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to store preset: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Stored current content as preset %d", slot))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// presetParams holds parameters for storing a preset
|
||||
type presetParams struct {
|
||||
slot int
|
||||
source string
|
||||
location string
|
||||
sourceAccount string
|
||||
name string
|
||||
itemType string
|
||||
artwork string
|
||||
}
|
||||
|
||||
// extractPresetParams extracts parameters from CLI context
|
||||
func extractPresetParams(c *cli.Context) *presetParams {
|
||||
return &presetParams{
|
||||
slot: c.Int("slot"),
|
||||
source: c.String("source"),
|
||||
location: c.String("location"),
|
||||
sourceAccount: c.String("source-account"),
|
||||
name: c.String("name"),
|
||||
itemType: c.String("type"),
|
||||
artwork: c.String("artwork"),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLocationAndMetadata resolves location and fetches metadata if needed
|
||||
func resolveLocationAndMetadata(params *presetParams) error {
|
||||
originalLocation := params.location
|
||||
resolvedSource, resolvedLocation := resolveLocation(params.source, params.location)
|
||||
|
||||
params.source = resolvedSource
|
||||
params.location = resolvedLocation
|
||||
|
||||
// If metadata (name or artwork) is missing, try to fetch it
|
||||
if params.name == "" || params.artwork == "" {
|
||||
var (
|
||||
metadata *Metadata
|
||||
err error
|
||||
)
|
||||
|
||||
if params.source == "TUNEIN" && strings.Contains(originalLocation, "tunein.com/radio/") {
|
||||
metadata, err = fetchTuneInMetadata(originalLocation)
|
||||
} else if params.source == "SPOTIFY" && strings.Contains(originalLocation, "open.spotify.com/") {
|
||||
metadata, err = fetchSpotifyMetadata(originalLocation)
|
||||
}
|
||||
|
||||
if err == nil && metadata != nil {
|
||||
if params.name == "" {
|
||||
params.name = metadata.Name
|
||||
}
|
||||
|
||||
if params.artwork == "" {
|
||||
params.artwork = metadata.Artwork
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePresetParams validates required preset parameters
|
||||
func validatePresetParams(params *presetParams) error {
|
||||
if params.source == "" {
|
||||
return fmt.Errorf("source is required (use --source)")
|
||||
}
|
||||
|
||||
if params.location == "" {
|
||||
return fmt.Errorf("location is required (use --location)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createContentItem creates a ContentItem from preset parameters
|
||||
func createContentItem(params *presetParams) *models.ContentItem {
|
||||
contentItem := &models.ContentItem{
|
||||
Source: params.source,
|
||||
Type: params.itemType,
|
||||
Location: params.location,
|
||||
SourceAccount: params.sourceAccount,
|
||||
IsPresetable: true,
|
||||
ItemName: params.name,
|
||||
ContainerArt: params.artwork,
|
||||
}
|
||||
|
||||
// Set default type if not specified
|
||||
if params.itemType == "" {
|
||||
switch params.source {
|
||||
case "SPOTIFY":
|
||||
contentItem.Type = "uri"
|
||||
case "TUNEIN", "LOCAL_INTERNET_RADIO":
|
||||
contentItem.Type = "stationurl"
|
||||
default:
|
||||
contentItem.Type = ""
|
||||
}
|
||||
}
|
||||
|
||||
return contentItem
|
||||
}
|
||||
|
||||
// printPresetContent displays what content will be stored
|
||||
func printPresetContent(params *presetParams) {
|
||||
fmt.Printf("Content to store:\n")
|
||||
fmt.Printf(" Name: %s\n", params.name)
|
||||
fmt.Printf(" Source: %s\n", params.source)
|
||||
fmt.Printf(" Location: %s\n", params.location)
|
||||
|
||||
if params.sourceAccount != "" {
|
||||
fmt.Printf(" Source Account: %s\n", params.sourceAccount)
|
||||
}
|
||||
|
||||
if params.itemType != "" {
|
||||
fmt.Printf(" Type: %s\n", params.itemType)
|
||||
}
|
||||
}
|
||||
|
||||
// storePreset handles storing specific content as preset
|
||||
func storePreset(c *cli.Context) error {
|
||||
// Extract parameters
|
||||
params := extractPresetParams(c)
|
||||
|
||||
// Resolve location and fetch metadata if needed
|
||||
if err := resolveLocationAndMetadata(params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if err := validatePresetParams(params); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Storing %s content as preset %d", params.source, params.slot), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
contentItem := createContentItem(params)
|
||||
printPresetContent(params)
|
||||
|
||||
// Store preset
|
||||
err = client.StorePreset(params.slot, contentItem)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to store preset: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Stored content as preset %d", params.slot))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removePreset handles removing a preset
|
||||
func removePreset(c *cli.Context) error {
|
||||
slot := c.Int("slot")
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Removing preset %d", slot), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if preset exists first
|
||||
presets, err := client.GetPresets()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get presets: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
preset := presets.GetPresetByID(slot)
|
||||
if preset == nil || preset.IsEmpty() {
|
||||
PrintError(fmt.Sprintf("Preset %d is already empty", slot))
|
||||
return fmt.Errorf("preset %d does not exist", slot)
|
||||
}
|
||||
|
||||
// Show what we're removing
|
||||
fmt.Printf("Removing preset %d:\n", slot)
|
||||
fmt.Printf(" Name: %s\n", preset.GetDisplayName())
|
||||
fmt.Printf(" Source: %s\n", preset.GetSource())
|
||||
|
||||
// Remove preset
|
||||
err = client.RemovePreset(slot)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove preset: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Removed preset %d", slot))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectPresetNew handles selecting a preset (new version that works with subcommands)
|
||||
func selectPresetNew(c *cli.Context) error {
|
||||
slot := c.Int("slot")
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Selecting preset %d", slot), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.SelectPreset(slot)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to select preset: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Preset %d selected", slot))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// listPresets handles listing all presets (alias for existing getPresets command)
|
||||
func listPresets(c *cli.Context) error {
|
||||
return getPresets(c)
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// getRecents handles getting recently played content
|
||||
func getRecents(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Getting recently played content", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get recent items: %w", err)
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
fmt.Printf("📭 No recent items found\n")
|
||||
fmt.Printf("💡 Play some content to populate the recent items list\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Display summary
|
||||
fmt.Printf("📊 Recent Items Summary:\n")
|
||||
fmt.Printf(" Total Items: %d\n", response.GetItemCount())
|
||||
|
||||
// Show source breakdown
|
||||
sources := map[string]int{
|
||||
"Spotify": len(response.GetSpotifyItems()),
|
||||
"Local Music": len(response.GetLocalMusicItems()),
|
||||
"Stored Music": len(response.GetStoredMusicItems()),
|
||||
"TuneIn": len(response.GetTuneInItems()),
|
||||
"Pandora": len(response.GetPandoraItems()),
|
||||
}
|
||||
|
||||
fmt.Printf(" By Source:\n")
|
||||
|
||||
for source, count := range sources {
|
||||
if count > 0 {
|
||||
fmt.Printf(" • %s: %d items\n", source, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Show type breakdown
|
||||
tracks := len(response.GetTracks())
|
||||
stations := len(response.GetStations())
|
||||
playlists := len(response.GetPlaylistsAndAlbums())
|
||||
presetable := len(response.GetPresetableItems())
|
||||
|
||||
fmt.Printf(" By Type:\n")
|
||||
|
||||
if tracks > 0 {
|
||||
fmt.Printf(" • 🎵 Tracks: %d\n", tracks)
|
||||
}
|
||||
|
||||
if stations > 0 {
|
||||
fmt.Printf(" • 📻 Stations: %d\n", stations)
|
||||
}
|
||||
|
||||
if playlists > 0 {
|
||||
fmt.Printf(" • 📋 Playlists/Albums: %d\n", playlists)
|
||||
}
|
||||
|
||||
if presetable > 0 {
|
||||
fmt.Printf(" • ⭐ Presetable: %d\n", presetable)
|
||||
}
|
||||
|
||||
fmt.Printf("\n=== Recent Items ===\n")
|
||||
|
||||
// Display items with details
|
||||
maxItems := c.Int("limit")
|
||||
if maxItems <= 0 || maxItems > len(response.Items) {
|
||||
maxItems = len(response.Items)
|
||||
}
|
||||
|
||||
for i, item := range response.Items[:maxItems] {
|
||||
printRecentItem(i+1, &item, c.Bool("detailed"))
|
||||
}
|
||||
|
||||
if len(response.Items) > maxItems {
|
||||
fmt.Printf("\n... and %d more items (use --limit to show more)\n", len(response.Items)-maxItems)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRecentsFiltered handles getting filtered recent content
|
||||
// buildFilterDescription creates a description string for the applied filters
|
||||
func buildFilterDescription(source, contentType string) string {
|
||||
switch {
|
||||
case source != "" && contentType != "":
|
||||
return fmt.Sprintf(" (filtered by source: %s, type: %s)", source, contentType)
|
||||
case source != "":
|
||||
return fmt.Sprintf(" (filtered by source: %s)", source)
|
||||
case contentType != "":
|
||||
return fmt.Sprintf(" (filtered by type: %s)", contentType)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// applyContentTypeFilter filters items by content type
|
||||
func applyContentTypeFilter(items []models.RecentsResponseItem, contentType string) []models.RecentsResponseItem {
|
||||
if contentType == "" {
|
||||
return items
|
||||
}
|
||||
|
||||
var typeFiltered []models.RecentsResponseItem
|
||||
|
||||
for _, item := range items {
|
||||
if shouldIncludeItemByType(item, contentType) {
|
||||
typeFiltered = append(typeFiltered, item)
|
||||
}
|
||||
}
|
||||
|
||||
return typeFiltered
|
||||
}
|
||||
|
||||
// shouldIncludeItemByType checks if an item matches the specified content type
|
||||
func shouldIncludeItemByType(item models.RecentsResponseItem, contentType string) bool {
|
||||
switch contentType {
|
||||
case "track", "tracks":
|
||||
return item.IsTrack()
|
||||
case "station", "stations":
|
||||
return item.IsStation()
|
||||
case "playlist", "playlists":
|
||||
return item.IsPlaylist()
|
||||
case "album", "albums":
|
||||
return item.IsAlbum()
|
||||
case "container", "containers":
|
||||
return item.IsContainer()
|
||||
case "presetable":
|
||||
return item.IsPresetable()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// displayFilteredResults prints the filtered recent items
|
||||
func displayFilteredResults(filteredItems []models.RecentsResponseItem, c *cli.Context) {
|
||||
maxItems := c.Int("limit")
|
||||
if maxItems <= 0 || maxItems > len(filteredItems) {
|
||||
maxItems = len(filteredItems)
|
||||
}
|
||||
|
||||
for i, item := range filteredItems[:maxItems] {
|
||||
printRecentItem(i+1, &item, c.Bool("detailed"))
|
||||
}
|
||||
|
||||
if len(filteredItems) > maxItems {
|
||||
fmt.Printf("\n... and %d more items (use --limit to show more)\n", len(filteredItems)-maxItems)
|
||||
}
|
||||
}
|
||||
|
||||
func getRecentsFiltered(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
source := strings.ToUpper(c.String("source"))
|
||||
contentType := strings.ToLower(c.String("type"))
|
||||
filterDesc := buildFilterDescription(source, contentType)
|
||||
|
||||
PrintDeviceHeader("Getting filtered recent content"+filterDesc, clientConfig.Host, clientConfig.Port)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get recent items: %w", err)
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
fmt.Printf("📭 No recent items found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply source filter
|
||||
var filteredItems []models.RecentsResponseItem
|
||||
if source != "" {
|
||||
filteredItems = response.GetItemsBySource(source)
|
||||
} else {
|
||||
filteredItems = response.Items
|
||||
}
|
||||
|
||||
// Apply type filter
|
||||
filteredItems = applyContentTypeFilter(filteredItems, contentType)
|
||||
|
||||
if len(filteredItems) == 0 {
|
||||
fmt.Printf("📭 No items match the specified filters\n")
|
||||
fmt.Printf("💡 Try different filter criteria or check available content\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("📊 Filtered Results: %d items\n\n", len(filteredItems))
|
||||
displayFilteredResults(filteredItems, c)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRecentsMostRecent shows only the most recent item
|
||||
func getRecentsMostRecent(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Getting most recent item", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get recent items: %w", err)
|
||||
}
|
||||
|
||||
mostRecent := response.GetMostRecent()
|
||||
if mostRecent == nil {
|
||||
fmt.Printf("📭 No recent items found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("🕒 Most Recent Item:\n\n")
|
||||
printRecentItem(1, mostRecent, true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printRecentItem prints details about a recent item
|
||||
func printRecentItem(index int, item *models.RecentsResponseItem, detailed bool) {
|
||||
// Basic information
|
||||
displayName := item.GetDisplayName()
|
||||
source := item.GetSource()
|
||||
contentType := item.GetContentType()
|
||||
|
||||
// Format source display
|
||||
sourceDisplay := formatSourceForDisplay(source)
|
||||
|
||||
// Content type icon
|
||||
typeIcon := getContentTypeIcon(item)
|
||||
|
||||
fmt.Printf("%d. %s %s\n", index, typeIcon, displayName)
|
||||
fmt.Printf(" Source: %s", sourceDisplay)
|
||||
|
||||
if contentType != "" {
|
||||
fmt.Printf(" | Type: %s", contentType)
|
||||
}
|
||||
|
||||
fmt.Printf("\n")
|
||||
|
||||
// Time information
|
||||
if item.GetUTCTime() > 0 {
|
||||
playTime := time.Unix(item.GetUTCTime(), 0)
|
||||
fmt.Printf(" Played: %s\n", playTime.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// Additional details if requested
|
||||
if detailed {
|
||||
if item.HasID() {
|
||||
fmt.Printf(" ID: %s\n", item.GetID())
|
||||
}
|
||||
|
||||
if item.IsPresetable() {
|
||||
fmt.Printf(" ⭐ Can be saved as preset\n")
|
||||
}
|
||||
|
||||
if item.HasArtwork() {
|
||||
fmt.Printf(" 🎨 Has artwork: %s\n", truncateString(item.GetArtwork(), 50))
|
||||
}
|
||||
|
||||
location := item.GetLocation()
|
||||
if location != "" {
|
||||
fmt.Printf(" 📍 Location: %s\n", truncateString(location, 50))
|
||||
}
|
||||
|
||||
sourceAccount := item.GetSourceAccount()
|
||||
if sourceAccount != "" && sourceAccount != source {
|
||||
fmt.Printf(" 👤 Account: %s\n", truncateString(sourceAccount, 30))
|
||||
}
|
||||
|
||||
// Content classification
|
||||
var classifications []string
|
||||
if item.IsStreamingContent() {
|
||||
classifications = append(classifications, "Streaming")
|
||||
}
|
||||
|
||||
if item.IsLocalContent() {
|
||||
classifications = append(classifications, "Local")
|
||||
}
|
||||
|
||||
if len(classifications) > 0 {
|
||||
fmt.Printf(" 🏷️ Classification: %s\n", strings.Join(classifications, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// getContentTypeIcon returns an emoji icon for the content type
|
||||
func getContentTypeIcon(item *models.RecentsResponseItem) string {
|
||||
switch {
|
||||
case item.IsTrack():
|
||||
return "🎵"
|
||||
case item.IsStation():
|
||||
return "📻"
|
||||
case item.IsPlaylist():
|
||||
return "📋"
|
||||
case item.IsAlbum():
|
||||
return "💿"
|
||||
case item.IsContainer():
|
||||
return "📁"
|
||||
default:
|
||||
return "🎶"
|
||||
}
|
||||
}
|
||||
|
||||
// formatSourceForDisplay formats source names for user-friendly display
|
||||
func formatSourceForDisplay(source string) string {
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
return "Spotify"
|
||||
case "LOCAL_MUSIC":
|
||||
return "Local Music"
|
||||
case "STORED_MUSIC":
|
||||
return "Stored Music"
|
||||
case "TUNEIN":
|
||||
return "TuneIn Radio"
|
||||
case "PANDORA":
|
||||
return "Pandora"
|
||||
case "AMAZON":
|
||||
return "Amazon Music"
|
||||
case "DEEZER":
|
||||
return "Deezer"
|
||||
case "IHEART":
|
||||
return "iHeartRadio"
|
||||
case "BLUETOOTH":
|
||||
return "Bluetooth"
|
||||
case "AUX":
|
||||
return "AUX Input"
|
||||
case "AIRPLAY":
|
||||
return "AirPlay"
|
||||
default:
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
// truncateString truncates a string to the specified length with ellipsis
|
||||
func truncateString(s string, maxLength int) string {
|
||||
if len(s) <= maxLength {
|
||||
return s
|
||||
}
|
||||
|
||||
if maxLength <= 3 {
|
||||
return "..."
|
||||
}
|
||||
|
||||
return s[:maxLength-3] + "..."
|
||||
}
|
||||
|
||||
// printBasicStats prints overall statistics about recent items
|
||||
func printBasicStats(response *models.RecentsResponse) {
|
||||
fmt.Printf("Overall Statistics:\n")
|
||||
fmt.Printf(" Total Items: %d\n", response.GetItemCount())
|
||||
|
||||
if !response.IsEmpty() {
|
||||
mostRecent := response.GetMostRecent()
|
||||
if mostRecent != nil {
|
||||
lastPlayTime := time.Unix(mostRecent.GetUTCTime(), 0)
|
||||
fmt.Printf(" Last Played: %s\n", lastPlayTime.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printSourceStats prints statistics broken down by source
|
||||
func printSourceStats(response *models.RecentsResponse) {
|
||||
fmt.Printf("\nBy Source:\n")
|
||||
|
||||
sourceStats := map[string]int{
|
||||
"Spotify": len(response.GetSpotifyItems()),
|
||||
"Pandora": len(response.GetPandoraItems()),
|
||||
"TuneIn": len(response.GetTuneInItems()),
|
||||
"Local Music": len(response.GetLocalMusicItems()),
|
||||
"Stored Music": len(response.GetStoredMusicItems()),
|
||||
}
|
||||
|
||||
// Add other sources if they exist
|
||||
otherSources := make(map[string]int)
|
||||
|
||||
for _, item := range response.Items {
|
||||
source := item.GetSource()
|
||||
found := false
|
||||
|
||||
for knownSource := range sourceStats {
|
||||
if strings.Contains(strings.ToLower(knownSource), strings.ToLower(source)) ||
|
||||
strings.Contains(strings.ToLower(source), strings.ToLower(knownSource)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found && source != "" {
|
||||
otherSources[formatSourceForDisplay(source)]++
|
||||
}
|
||||
}
|
||||
|
||||
// Merge other sources
|
||||
for source, count := range otherSources {
|
||||
sourceStats[source] = count
|
||||
}
|
||||
|
||||
for source, count := range sourceStats {
|
||||
if count > 0 {
|
||||
percentage := float64(count) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", source+":", count, percentage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printContentTypeStats prints statistics broken down by content type
|
||||
func printContentTypeStats(response *models.RecentsResponse) {
|
||||
fmt.Printf("\nBy Content Type:\n")
|
||||
|
||||
tracks := len(response.GetTracks())
|
||||
stations := len(response.GetStations())
|
||||
playlists := len(response.GetPlaylistsAndAlbums())
|
||||
|
||||
if tracks > 0 {
|
||||
percentage := float64(tracks) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Tracks:", tracks, percentage)
|
||||
}
|
||||
|
||||
if stations > 0 {
|
||||
percentage := float64(stations) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Stations:", stations, percentage)
|
||||
}
|
||||
|
||||
if playlists > 0 {
|
||||
percentage := float64(playlists) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Playlists/Albums:", playlists, percentage)
|
||||
}
|
||||
}
|
||||
|
||||
// printSpecialCategoryStats prints statistics for special content categories
|
||||
func printSpecialCategoryStats(response *models.RecentsResponse) {
|
||||
presetable := len(response.GetPresetableItems())
|
||||
if presetable > 0 {
|
||||
fmt.Printf("\nSpecial Categories:\n")
|
||||
|
||||
percentage := float64(presetable) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Presetable:", presetable, percentage)
|
||||
}
|
||||
}
|
||||
|
||||
// printSourceAnalysisStats prints streaming vs local content analysis
|
||||
func printSourceAnalysisStats(response *models.RecentsResponse) {
|
||||
streamingCount := 0
|
||||
localCount := 0
|
||||
|
||||
for _, item := range response.Items {
|
||||
if item.IsStreamingContent() {
|
||||
streamingCount++
|
||||
} else if item.IsLocalContent() {
|
||||
localCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSource Analysis:\n")
|
||||
|
||||
if streamingCount > 0 {
|
||||
percentage := float64(streamingCount) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Streaming:", streamingCount, percentage)
|
||||
}
|
||||
|
||||
if localCount > 0 {
|
||||
percentage := float64(localCount) / float64(response.GetItemCount()) * 100
|
||||
fmt.Printf(" %-15s %3d items (%5.1f%%)\n", "Local:", localCount, percentage)
|
||||
}
|
||||
}
|
||||
|
||||
// recentsStats shows statistics about recent items
|
||||
func recentsStats(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Getting recent items statistics", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
response, err := client.GetRecents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get recent items: %w", err)
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
fmt.Printf("📊 Statistics: No recent items found\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("📊 Recent Items Statistics\n\n")
|
||||
|
||||
printBasicStats(response)
|
||||
printSourceStats(response)
|
||||
printContentTypeStats(response)
|
||||
printSpecialCategoryStats(response)
|
||||
printSourceAnalysisStats(response)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestRecentsCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedOutput []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "recents list command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "list"},
|
||||
expectedOutput: []string{
|
||||
"Getting recently played content",
|
||||
"Recent Items Summary:",
|
||||
"Recent Items",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recents filter by source",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "filter", "--source", "SPOTIFY"},
|
||||
expectedOutput: []string{
|
||||
"Getting filtered recent content",
|
||||
"filtered by source: SPOTIFY",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recents latest command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "latest"},
|
||||
expectedOutput: []string{
|
||||
"Getting most recent item",
|
||||
"Most Recent Item:",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recents stats command",
|
||||
args: []string{"soundtouch-cli", "--host", "192.168.1.100", "recents", "stats"},
|
||||
expectedOutput: []string{
|
||||
"Getting recent items statistics",
|
||||
"Recent Items Statistics",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "recents missing host",
|
||||
args: []string{"soundtouch-cli", "recents", "list"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Skip actual execution for now - these would need mock HTTP servers
|
||||
// This test structure shows how the CLI commands would be tested
|
||||
t.Skip("Integration test - requires mock HTTP server setup")
|
||||
|
||||
// Example of how you would set up the test:
|
||||
// app := createTestApp()
|
||||
//
|
||||
// var buf bytes.Buffer
|
||||
// app.Writer = &buf
|
||||
// app.ErrWriter = &buf
|
||||
//
|
||||
// err := app.Run(tt.args)
|
||||
//
|
||||
// if tt.expectError {
|
||||
// if err == nil {
|
||||
// t.Error("expected error, got nil")
|
||||
// }
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// if err != nil {
|
||||
// t.Fatalf("unexpected error: %v", err)
|
||||
// }
|
||||
//
|
||||
// output := buf.String()
|
||||
// for _, expected := range tt.expectedOutput {
|
||||
// if !strings.Contains(output, expected) {
|
||||
// t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
// }
|
||||
// }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintRecentItem(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item *models.RecentsResponseItem
|
||||
detailed bool
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "basic track item",
|
||||
item: &models.RecentsResponseItem{
|
||||
DeviceID: "device1",
|
||||
UTCTime: 1701200000,
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "track",
|
||||
ItemName: "Test Song",
|
||||
},
|
||||
},
|
||||
detailed: false,
|
||||
expected: []string{
|
||||
"🎵 Test Song",
|
||||
"Source: Spotify",
|
||||
"Type: track",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "detailed station item",
|
||||
item: &models.RecentsResponseItem{
|
||||
DeviceID: "device1",
|
||||
UTCTime: 1701200000,
|
||||
ID: "station123",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
ItemName: "Rock FM",
|
||||
Location: "tunein:station:s12345",
|
||||
SourceAccount: "tunein_account",
|
||||
IsPresetable: true,
|
||||
},
|
||||
},
|
||||
detailed: true,
|
||||
expected: []string{
|
||||
"📻 Rock FM",
|
||||
"Source: TuneIn Radio",
|
||||
"ID: station123",
|
||||
"Can be saved as preset",
|
||||
"Location: tunein:station:s12345",
|
||||
"Classification: Streaming",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Capture stdout
|
||||
oldStdout := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
// Call the function
|
||||
printRecentItem(1, tt.item, tt.detailed)
|
||||
|
||||
// Restore stdout and read output
|
||||
w.Close()
|
||||
|
||||
os.Stdout = oldStdout
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
_, err := buf.ReadFrom(r)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check expected strings are present
|
||||
for _, expected := range tt.expected {
|
||||
if !bytes.Contains(buf.Bytes(), []byte(expected)) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", expected, output)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContentTypeIcon(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item *models.RecentsResponseItem
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "track item",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "track"},
|
||||
},
|
||||
expected: "🎵",
|
||||
},
|
||||
{
|
||||
name: "station item",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "stationurl"},
|
||||
},
|
||||
expected: "📻",
|
||||
},
|
||||
{
|
||||
name: "playlist item",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "playlist"},
|
||||
},
|
||||
expected: "📋",
|
||||
},
|
||||
{
|
||||
name: "album item",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "album"},
|
||||
},
|
||||
expected: "💿",
|
||||
},
|
||||
{
|
||||
name: "container item",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "container"},
|
||||
},
|
||||
expected: "📁",
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
item: &models.RecentsResponseItem{
|
||||
ContentItem: &models.ContentItem{Type: "unknown"},
|
||||
},
|
||||
expected: "🎶",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getContentTypeIcon(tt.item)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSourceForDisplay(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
expected string
|
||||
}{
|
||||
{"Spotify", "SPOTIFY", "Spotify"},
|
||||
{"Local Music", "LOCAL_MUSIC", "Local Music"},
|
||||
{"Stored Music", "STORED_MUSIC", "Stored Music"},
|
||||
{"TuneIn", "TUNEIN", "TuneIn Radio"},
|
||||
{"Pandora", "PANDORA", "Pandora"},
|
||||
{"Amazon", "AMAZON", "Amazon Music"},
|
||||
{"Deezer", "DEEZER", "Deezer"},
|
||||
{"iHeart", "IHEART", "iHeartRadio"},
|
||||
{"Bluetooth", "BLUETOOTH", "Bluetooth"},
|
||||
{"AUX", "AUX", "AUX Input"},
|
||||
{"AirPlay", "AIRPLAY", "AirPlay"},
|
||||
{"Unknown", "UNKNOWN_SOURCE", "UNKNOWN_SOURCE"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := formatSourceForDisplay(tt.source)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
maxLength int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "short string",
|
||||
input: "hello",
|
||||
maxLength: 10,
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
name: "exact length",
|
||||
input: "hello",
|
||||
maxLength: 5,
|
||||
expected: "hello",
|
||||
},
|
||||
{
|
||||
name: "long string",
|
||||
input: "this is a very long string that needs truncation",
|
||||
maxLength: 20,
|
||||
expected: "this is a very lo...",
|
||||
},
|
||||
{
|
||||
name: "very short max length",
|
||||
input: "hello world",
|
||||
maxLength: 3,
|
||||
expected: "...",
|
||||
},
|
||||
{
|
||||
name: "zero length",
|
||||
input: "hello",
|
||||
maxLength: 0,
|
||||
expected: "...",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := truncateString(tt.input, tt.maxLength)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test helper functions that would be used in full integration tests
|
||||
func createTestRecentsResponse() *models.RecentsResponse {
|
||||
return &models.RecentsResponse{
|
||||
Items: []models.RecentsResponseItem{
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701300000,
|
||||
ID: "spotify1",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
Type: "track",
|
||||
Location: "spotify:track:4iV5W9uYEdYUVa79Axb7Rh",
|
||||
SourceAccount: "spotify_user",
|
||||
IsPresetable: true,
|
||||
ItemName: "Shape of You - Ed Sheeran",
|
||||
ContainerArt: "https://i.scdn.co/image/ab67616d0000b273ba5db46f4b838ef6027e6f96",
|
||||
},
|
||||
},
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701200000,
|
||||
ID: "local1",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "LOCAL_MUSIC",
|
||||
Type: "track",
|
||||
Location: "/music/local_song.mp3",
|
||||
IsPresetable: false,
|
||||
ItemName: "Local Song - Local Artist",
|
||||
},
|
||||
},
|
||||
{
|
||||
DeviceID: "1004567890AA",
|
||||
UTCTime: 1701100000,
|
||||
ID: "tunein1",
|
||||
ContentItem: &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: "stationurl",
|
||||
Location: "tunein:station:s24939",
|
||||
SourceAccount: "tunein",
|
||||
IsPresetable: true,
|
||||
ItemName: "BBC Radio 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTestRecentsResponse(t *testing.T) {
|
||||
response := createTestRecentsResponse()
|
||||
|
||||
if response == nil {
|
||||
t.Fatal("expected response, got nil")
|
||||
}
|
||||
|
||||
if response.GetItemCount() != 3 {
|
||||
t.Errorf("expected 3 items, got %d", response.GetItemCount())
|
||||
}
|
||||
|
||||
if response.IsEmpty() {
|
||||
t.Error("expected response not to be empty")
|
||||
}
|
||||
|
||||
// Test filtering
|
||||
spotifyItems := response.GetSpotifyItems()
|
||||
if len(spotifyItems) != 1 {
|
||||
t.Errorf("expected 1 Spotify item, got %d", len(spotifyItems))
|
||||
}
|
||||
|
||||
localItems := response.GetLocalMusicItems()
|
||||
if len(localItems) != 1 {
|
||||
t.Errorf("expected 1 local music item, got %d", len(localItems))
|
||||
}
|
||||
|
||||
tuneInItems := response.GetTuneInItems()
|
||||
if len(tuneInItems) != 1 {
|
||||
t.Errorf("expected 1 TuneIn item, got %d", len(tuneInItems))
|
||||
}
|
||||
|
||||
tracks := response.GetTracks()
|
||||
if len(tracks) != 2 {
|
||||
t.Errorf("expected 2 tracks, got %d", len(tracks))
|
||||
}
|
||||
|
||||
stations := response.GetStations()
|
||||
if len(stations) != 1 {
|
||||
t.Errorf("expected 1 station, got %d", len(stations))
|
||||
}
|
||||
|
||||
presetableItems := response.GetPresetableItems()
|
||||
if len(presetableItems) != 2 {
|
||||
t.Errorf("expected 2 presetable items, got %d", len(presetableItems))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,310 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
)
|
||||
|
||||
// captureStdout runs fn and returns whatever it wrote to os.Stdout.
|
||||
// renderSourceTable prints directly via fmt.Print* — this lets us assert
|
||||
// on its output without restructuring the renderer to take an io.Writer.
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
orig := os.Stdout
|
||||
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
|
||||
os.Stdout = w
|
||||
|
||||
done := make(chan struct{})
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
go func() {
|
||||
_, _ = io.Copy(buf, r)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
fn()
|
||||
_ = w.Close()
|
||||
|
||||
os.Stdout = orig
|
||||
<-done
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestRenderSourceTable_AlignsColumnsAndDedupsDisplayName(t *testing.T) {
|
||||
items := []models.SourceItem{
|
||||
// displayName != account → kept as "AUX (AUX IN)"
|
||||
{Source: "AUX", SourceAccount: "AUX", DisplayName: "AUX IN", Status: "READY", IsLocal: true, MultiroomAllowed: true},
|
||||
// displayName == account → dropped (would otherwise duplicate the next column)
|
||||
{Source: "AMAZON", SourceAccount: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", DisplayName: "amzn1.account.AFKTQOUNVZL7ODQCF4STPAAMVMPA", Status: "READY", MultiroomAllowed: true},
|
||||
// No displayName at all, no account
|
||||
{Source: "BLUETOOTH", Status: "UNAVAILABLE", IsLocal: true, MultiroomAllowed: true},
|
||||
// Long source name, no catalog entry → provider#?
|
||||
{Source: "STORED_MUSIC_MEDIA_RENDERER", SourceAccount: "StoredMusicUserName", DisplayName: "StoredMusicUserName", Status: "UNAVAILABLE", MultiroomAllowed: true},
|
||||
}
|
||||
|
||||
out := captureStdout(t, func() { renderSourceTable(items) })
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("got %d output lines, want 4:\n%s", len(lines), out)
|
||||
}
|
||||
|
||||
// (1) AUX keeps "(AUX IN)" because it differs from both source and account.
|
||||
if !strings.Contains(lines[0], "AUX (AUX IN)") {
|
||||
t.Errorf("AUX line should keep displayName parenthesis: %q", lines[0])
|
||||
}
|
||||
|
||||
// (2) AMAZON drops "(amzn1…)" because displayName equals sourceAccount.
|
||||
if strings.Contains(lines[1], "(amzn1.account") {
|
||||
t.Errorf("AMAZON line should drop displayName when it duplicates account: %q", lines[1])
|
||||
}
|
||||
|
||||
// (3) provider#? for the uncatalogued source.
|
||||
if !strings.Contains(lines[3], "provider#?") {
|
||||
t.Errorf("uncatalogued source should be tagged provider#?: %q", lines[3])
|
||||
}
|
||||
|
||||
// (4) Column starts must align across all rows — find the column index
|
||||
// where "status=" appears in each line; they should all match.
|
||||
statusCols := make([]int, len(lines))
|
||||
for i, l := range lines {
|
||||
statusCols[i] = strings.Index(l, "status=")
|
||||
if statusCols[i] < 0 {
|
||||
t.Fatalf("line %d missing status= column: %q", i, l)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < len(statusCols); i++ {
|
||||
if statusCols[i] != statusCols[0] {
|
||||
t.Errorf("status= column misaligned: line 0 at col %d, line %d at col %d\n%s",
|
||||
statusCols[0], i, statusCols[i], out)
|
||||
}
|
||||
}
|
||||
|
||||
// (5) account= column should likewise align across all rows.
|
||||
accountCols := make([]int, len(lines))
|
||||
for i, l := range lines {
|
||||
accountCols[i] = strings.Index(l, "account=")
|
||||
if accountCols[i] < 0 {
|
||||
t.Fatalf("line %d missing account= column: %q", i, l)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 1; i < len(accountCols); i++ {
|
||||
if accountCols[i] != accountCols[0] {
|
||||
t.Errorf("account= column misaligned: line 0 at col %d, line %d at col %d\n%s",
|
||||
accountCols[0], i, accountCols[i], out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSourceTable_EmptyShowsNonePlaceholder(t *testing.T) {
|
||||
out := captureStdout(t, func() { renderSourceTable(nil) })
|
||||
if !strings.Contains(out, "(none)") {
|
||||
t.Errorf("expected (none) placeholder for empty list, got: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_PrefersTelnet(t *testing.T) {
|
||||
method, reason := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
|
||||
TelnetReachable: true,
|
||||
SSHSuccess: true,
|
||||
})
|
||||
|
||||
if method != setup.MigrationMethodTelnet {
|
||||
t.Errorf("method = %q, want telnet (simplest path when telnet works)", method)
|
||||
}
|
||||
|
||||
if !strings.Contains(reason, "Telnet") {
|
||||
t.Errorf("reason should mention Telnet: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_HTTPSAddsCaveatToTelnet(t *testing.T) {
|
||||
_, reason := recommendMigrationMethod("https://aftertouch.local:8443", &setup.MigrationSummary{
|
||||
TelnetReachable: true,
|
||||
})
|
||||
|
||||
if !strings.Contains(reason, "install-ca") {
|
||||
t.Errorf("HTTPS service URL should flag the CA-install caveat in the reason: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_FallsBackToResolvWhenTelnetDown(t *testing.T) {
|
||||
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
|
||||
TelnetReachable: false,
|
||||
SSHSuccess: true,
|
||||
})
|
||||
|
||||
if method != setup.MigrationMethodResolvConf {
|
||||
t.Errorf("method = %q, want resolv (DNS redirect via SSH)", method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendMigrationMethod_EmptyWhenNoTransport(t *testing.T) {
|
||||
method, _ := recommendMigrationMethod("http://aftertouch.local:8000", &setup.MigrationSummary{
|
||||
TelnetReachable: false,
|
||||
SSHSuccess: false,
|
||||
})
|
||||
|
||||
if method != "" {
|
||||
t.Errorf("method = %q, want empty when no transport works", method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_NoOpWhenAlreadyMigratedAndPaired(t *testing.T) {
|
||||
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true, TelnetMigrated: true}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
|
||||
|
||||
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
|
||||
|
||||
if len(steps) != 0 {
|
||||
t.Errorf("expected no steps for fully-set-up device, got %d:\n%v", len(steps), steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_RecommendsPairWhenMigratedButUnpaired(t *testing.T) {
|
||||
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: false, TelnetMigrated: true, TelnetReachable: true}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
|
||||
|
||||
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
|
||||
|
||||
if len(steps) != 1 {
|
||||
t.Fatalf("expected exactly the pair step, got %d:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "setup pair") {
|
||||
t.Errorf("expected pair command, got %q", steps[0].cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_MigrateRebootThenPairWhenFresh(t *testing.T) {
|
||||
summary := &setup.MigrationSummary{TelnetReachable: true, SSHSuccess: false, IsPaired: false}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "AABBCCDDEEFF"}}
|
||||
|
||||
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, false, inspect, summary)
|
||||
|
||||
// migrate → reboot → pair. The reboot step exists because envswitch's
|
||||
// parallel-persistence layer only fully wins on the next boot, and we
|
||||
// want the new URLs locked in before pairing posts to the speaker.
|
||||
if len(steps) != 3 {
|
||||
t.Fatalf("expected migrate+reboot+pair, got %d steps:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "setup migrate") || !strings.Contains(steps[0].cmd, "method=telnet") {
|
||||
t.Errorf("step 1 should be telnet migrate, got %q", steps[0].cmd)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[1].cmd, "setup reboot") {
|
||||
t.Errorf("step 2 should be reboot, got %q", steps[1].cmd)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[2].cmd, "setup pair") {
|
||||
t.Errorf("step 3 should be pair, got %q", steps[2].cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_DNSMethodPrependsCAInstall(t *testing.T) {
|
||||
// Telnet down, SSH up, CA not yet trusted → plan must install-ca
|
||||
// before applying the resolv migration.
|
||||
summary := &setup.MigrationSummary{
|
||||
TelnetReachable: false,
|
||||
SSHSuccess: true,
|
||||
CACertTrusted: false,
|
||||
IsPaired: false,
|
||||
}
|
||||
inspect := &setup.InspectReport{Info: &setup.DeviceInfoXML{DeviceID: "X"}}
|
||||
|
||||
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", false, false, inspect, summary)
|
||||
|
||||
if len(steps) < 2 {
|
||||
t.Fatalf("expected at least install-ca + migrate, got %d steps:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "install-ca") {
|
||||
t.Errorf("install-ca should come first when DNS method is chosen and CA is not trusted, got %q", steps[0].cmd)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[1].cmd, "method=resolv") {
|
||||
t.Errorf("step 2 should be resolv migrate, got %q", steps[1].cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSteps_ResetModeIncludesManualNetworkSwitches(t *testing.T) {
|
||||
inspect := &setup.InspectReport{
|
||||
Info: &setup.DeviceInfoXML{DeviceID: "506583DE4803"},
|
||||
Network: &models.NetworkInformation{
|
||||
Interfaces: models.NetworkInterfaces{
|
||||
Interfaces: []models.NetworkInterface{
|
||||
{Type: "WIFI_INTERFACE", SSID: "MyHomeNetwork"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
summary := &setup.MigrationSummary{IsMigrated: true, IsPaired: true} // doesn't matter in reset mode
|
||||
|
||||
steps := buildPlanSteps("192.168.1.42", "http://aftertouch.local:8000", "", true, true, inspect, summary)
|
||||
|
||||
// Expected sequence in --reset mode:
|
||||
// factory-reset, manual AP switch, wait-ap, wifi-push, manual home switch,
|
||||
// wait-online, migrate, pair (8 steps).
|
||||
if len(steps) < 7 {
|
||||
t.Fatalf("expected at least 7 steps in --reset mode, got %d:\n%v", len(steps), steps)
|
||||
}
|
||||
|
||||
manualCount := 0
|
||||
for _, s := range steps {
|
||||
if s.manual {
|
||||
manualCount++
|
||||
}
|
||||
}
|
||||
|
||||
if manualCount < 2 {
|
||||
t.Errorf("expected at least 2 manual steps for the Wi-Fi switches, got %d", manualCount)
|
||||
}
|
||||
|
||||
if !strings.Contains(steps[0].cmd, "factory-reset") {
|
||||
t.Errorf("step 1 must be factory-reset, got %q", steps[0].cmd)
|
||||
}
|
||||
|
||||
// wifi-push step should default to the inspected SSID
|
||||
foundWiFi := false
|
||||
|
||||
for _, s := range steps {
|
||||
if strings.Contains(s.cmd, "wifi-push") && strings.Contains(s.cmd, "MyHomeNetwork") {
|
||||
foundWiFi = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundWiFi {
|
||||
t.Errorf("expected wifi-push step to default to inspected SSID 'MyHomeNetwork'")
|
||||
}
|
||||
|
||||
// wait-online --match should use the deviceID suffix
|
||||
foundMatch := false
|
||||
|
||||
for _, s := range steps {
|
||||
if strings.Contains(s.cmd, "wait-online") && strings.Contains(s.cmd, "--match=DE4803") {
|
||||
foundMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundMatch {
|
||||
t.Errorf("expected wait-online step to use --match=DE4803 from deviceID suffix")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func printSource(source models.SourceItem) {
|
||||
fmt.Printf(" • %s", source.GetDisplayName())
|
||||
|
||||
if source.SourceAccount != "" && source.SourceAccount != source.Source {
|
||||
fmt.Printf(" (%s)", source.SourceAccount)
|
||||
}
|
||||
|
||||
var attributes []string
|
||||
if source.IsLocalSource() {
|
||||
attributes = append(attributes, "Local")
|
||||
attributes = append(attributes, "Available")
|
||||
}
|
||||
|
||||
if len(attributes) > 0 {
|
||||
fmt.Printf(" [%s]", strings.Join(attributes, ", "))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// listSources handles listing available audio sources
|
||||
func listSources(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
@@ -32,26 +55,7 @@ func listSources(c *cli.Context) error {
|
||||
fmt.Printf(" Ready Sources:\n")
|
||||
|
||||
for _, source := range availableSources {
|
||||
fmt.Printf(" • %s", source.GetDisplayName())
|
||||
|
||||
if source.SourceAccount != "" && source.SourceAccount != source.Source {
|
||||
fmt.Printf(" (%s)", source.SourceAccount)
|
||||
}
|
||||
|
||||
var attributes []string
|
||||
if source.IsLocalSource() {
|
||||
attributes = append(attributes, "Local")
|
||||
}
|
||||
|
||||
if source.IsLocalSource() {
|
||||
attributes = append(attributes, "Available")
|
||||
}
|
||||
|
||||
if len(attributes) > 0 {
|
||||
fmt.Printf(" [%s]", strings.Join(attributes, ", "))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
printSource(source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +91,12 @@ func listSources(c *cli.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Show service availability summary
|
||||
fmt.Println()
|
||||
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
checker.PrintServiceAvailabilitySummary()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -102,6 +112,14 @@ func selectSource(c *cli.Context) error {
|
||||
sourceName := strings.ToUpper(c.String("source"))
|
||||
sourceAccount := c.String("account")
|
||||
|
||||
// Check service availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
|
||||
actionDescription := fmt.Sprintf("select %s source", strings.ToLower(sourceName))
|
||||
if !checker.CheckSourceAvailable(sourceName, actionDescription) {
|
||||
return fmt.Errorf("source '%s' is not available", sourceName)
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Selecting source '%s'", sourceName), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
err = client.SelectSource(sourceName, sourceAccount)
|
||||
@@ -127,6 +145,12 @@ func selectSpotify(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check Spotify availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.ValidateSpotifyAvailable("select Spotify source") {
|
||||
return fmt.Errorf("spotify is not available on this device")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting Spotify source", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
err = client.SelectSpotify("")
|
||||
@@ -148,6 +172,12 @@ func selectBluetooth(c *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check Bluetooth availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.ValidateBluetoothAvailable("select Bluetooth source") {
|
||||
return fmt.Errorf("bluetooth is not available on this device")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting Bluetooth source", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
err = client.SelectBluetooth()
|
||||
@@ -180,3 +210,478 @@ func selectAux(c *cli.Context) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectLocalInternetRadio handles selecting LOCAL_INTERNET_RADIO source
|
||||
func selectLocalInternetRadio(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
location := c.String("location")
|
||||
if location == "" {
|
||||
return fmt.Errorf("location is required (use --location)")
|
||||
}
|
||||
|
||||
sourceAccount := c.String("account")
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
|
||||
// Check LOCAL_INTERNET_RADIO availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select internet radio") {
|
||||
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting internet radio stream", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Station: %s\n", itemName)
|
||||
}
|
||||
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
|
||||
err = client.SelectLocalInternetRadio(location, sourceAccount, itemName, containerArt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select internet radio: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Internet radio stream selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectCustomRadio handles selecting custom radio stream via soundtouch-service
|
||||
func selectCustomRadio(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streamURL := c.String("url")
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
serviceURL := c.String("service-url")
|
||||
|
||||
encodedURL := base64.URLEncoding.EncodeToString([]byte(streamURL))
|
||||
location := fmt.Sprintf("%s/custom/v1/playback/%s", serviceURL, encodedURL)
|
||||
|
||||
params := url.Values{}
|
||||
if itemName != "" {
|
||||
params.Add("name", itemName)
|
||||
}
|
||||
|
||||
if containerArt != "" {
|
||||
params.Add("imageUrl", containerArt)
|
||||
}
|
||||
|
||||
if len(params) > 0 {
|
||||
location += "?" + params.Encode()
|
||||
}
|
||||
|
||||
// Check LOCAL_INTERNET_RADIO availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select custom radio") {
|
||||
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting custom radio stream", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Station: %s\n", itemName)
|
||||
}
|
||||
|
||||
fmt.Printf(" URL: %s\n", streamURL)
|
||||
fmt.Printf(" Proxy: %s\n", location)
|
||||
|
||||
err = client.SelectLocalInternetRadio(location, "", itemName, containerArt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select custom radio: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Custom radio stream selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectLocalMusic handles selecting LOCAL_MUSIC source
|
||||
func selectLocalMusic(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
location := c.String("location")
|
||||
if location == "" {
|
||||
return fmt.Errorf("location is required (use --location)")
|
||||
}
|
||||
|
||||
sourceAccount := c.String("account")
|
||||
if sourceAccount == "" {
|
||||
return fmt.Errorf("account is required for LOCAL_MUSIC (use --account)")
|
||||
}
|
||||
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
|
||||
// Check LOCAL_MUSIC availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable("LOCAL_MUSIC", "select local music") {
|
||||
return fmt.Errorf("LOCAL_MUSIC is not available")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting local music content", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Content: %s\n", itemName)
|
||||
}
|
||||
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
fmt.Printf(" Account: %s\n", sourceAccount)
|
||||
|
||||
err = client.SelectLocalMusic(location, sourceAccount, itemName, containerArt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select local music: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Local music content selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectStoredMusic handles selecting STORED_MUSIC source
|
||||
func selectStoredMusic(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
location := c.String("location")
|
||||
if location == "" {
|
||||
return fmt.Errorf("location is required (use --location)")
|
||||
}
|
||||
|
||||
sourceAccount := c.String("account")
|
||||
if sourceAccount == "" {
|
||||
return fmt.Errorf("account is required for STORED_MUSIC (use --account)")
|
||||
}
|
||||
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
|
||||
// Check STORED_MUSIC availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable("STORED_MUSIC", "select stored music") {
|
||||
return fmt.Errorf("STORED_MUSIC is not available")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting stored music content", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Content: %s\n", itemName)
|
||||
}
|
||||
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
fmt.Printf(" Account: %s\n", sourceAccount)
|
||||
|
||||
err = client.SelectStoredMusic(location, sourceAccount, itemName, containerArt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select stored music: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Stored music content selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectContent handles selecting content using a ContentItem directly
|
||||
func selectContent(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Required parameters
|
||||
source := strings.ToUpper(c.String("source"))
|
||||
if source == "" {
|
||||
return fmt.Errorf("source is required (use --source)")
|
||||
}
|
||||
|
||||
location := c.String("location")
|
||||
if location == "" {
|
||||
return fmt.Errorf("location is required (use --location)")
|
||||
}
|
||||
|
||||
// Optional parameters
|
||||
sourceAccount := c.String("account")
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
itemType := c.String("type")
|
||||
isPresetable := c.Bool("presetable")
|
||||
|
||||
// Create ContentItem
|
||||
contentItem := &models.ContentItem{
|
||||
Source: source,
|
||||
Type: itemType,
|
||||
Location: location,
|
||||
SourceAccount: sourceAccount,
|
||||
IsPresetable: isPresetable,
|
||||
ItemName: itemName,
|
||||
ContainerArt: containerArt,
|
||||
}
|
||||
|
||||
// Set default type if not specified
|
||||
if itemType == "" {
|
||||
switch source {
|
||||
case "SPOTIFY":
|
||||
contentItem.Type = "uri"
|
||||
case "TUNEIN", "LOCAL_INTERNET_RADIO":
|
||||
contentItem.Type = "stationurl"
|
||||
case "LOCAL_MUSIC":
|
||||
contentItem.Type = "album" // default, could be track, artist, etc.
|
||||
}
|
||||
}
|
||||
|
||||
// Set default item name if not specified
|
||||
if itemName == "" {
|
||||
contentItem.ItemName = source
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting content", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
fmt.Printf(" Source: %s\n", source)
|
||||
fmt.Printf(" Location: %s\n", location)
|
||||
|
||||
if sourceAccount != "" {
|
||||
fmt.Printf(" Account: %s\n", sourceAccount)
|
||||
}
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Name: %s\n", itemName)
|
||||
}
|
||||
|
||||
if itemType != "" {
|
||||
fmt.Printf(" Type: %s\n", itemType)
|
||||
}
|
||||
|
||||
err = client.SelectContentItem(contentItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select content: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Content selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getServiceAvailability handles displaying service availability information
|
||||
func getServiceAvailability(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Getting service availability", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get service availability: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Service Availability Report:\n")
|
||||
fmt.Printf(" Total Services: %d\n", serviceAvailability.GetServiceCount())
|
||||
fmt.Printf(" Available Services: %d\n", serviceAvailability.GetAvailableServiceCount())
|
||||
fmt.Printf(" Unavailable Services: %d\n", serviceAvailability.GetUnavailableServiceCount())
|
||||
|
||||
// Show available services
|
||||
fmt.Printf("\n✅ Available Services:\n")
|
||||
|
||||
availableServices := serviceAvailability.GetAvailableServices()
|
||||
if len(availableServices) == 0 {
|
||||
fmt.Printf(" None\n")
|
||||
} else {
|
||||
for _, service := range availableServices {
|
||||
fmt.Printf(" • %s\n", formatServiceTypeForDisplay(models.ServiceType(service.Type)))
|
||||
}
|
||||
}
|
||||
|
||||
// Show unavailable services with reasons
|
||||
fmt.Printf("\n❌ Unavailable Services:\n")
|
||||
|
||||
unavailableServices := serviceAvailability.GetUnavailableServices()
|
||||
if len(unavailableServices) == 0 {
|
||||
fmt.Printf(" None\n")
|
||||
} else {
|
||||
for _, service := range unavailableServices {
|
||||
reason := ""
|
||||
if service.Reason != "" {
|
||||
reason = fmt.Sprintf(" (%s)", service.Reason)
|
||||
}
|
||||
|
||||
fmt.Printf(" • %s%s\n", formatServiceTypeForDisplay(models.ServiceType(service.Type)), reason)
|
||||
}
|
||||
}
|
||||
|
||||
// Show service categories
|
||||
fmt.Printf("\n🎵 Streaming Services:\n")
|
||||
|
||||
streamingServices := serviceAvailability.GetStreamingServices()
|
||||
availableCount := 0
|
||||
|
||||
for _, service := range streamingServices {
|
||||
status := "❌"
|
||||
if service.IsAvailable {
|
||||
status = "✅"
|
||||
availableCount++
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", status, formatServiceTypeForDisplay(models.ServiceType(service.Type)))
|
||||
}
|
||||
|
||||
fmt.Printf(" Summary: %d/%d streaming services available\n", availableCount, len(streamingServices))
|
||||
|
||||
fmt.Printf("\n🔗 Local Input Services:\n")
|
||||
|
||||
localServices := serviceAvailability.GetLocalServices()
|
||||
localAvailableCount := 0
|
||||
|
||||
for _, service := range localServices {
|
||||
status := "❌"
|
||||
if service.IsAvailable {
|
||||
status = "✅"
|
||||
localAvailableCount++
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", status, formatServiceTypeForDisplay(models.ServiceType(service.Type)))
|
||||
}
|
||||
|
||||
fmt.Printf(" Summary: %d/%d local services available\n", localAvailableCount, len(localServices))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// compareSourcesAndAvailability compares configured sources with service availability
|
||||
func compareSourcesAndAvailability(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Comparing sources and service availability", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
// Get both sources and service availability
|
||||
sources, err := client.GetSources()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sources: %w", err)
|
||||
}
|
||||
|
||||
serviceAvailability, err := client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get service availability: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Source vs Availability Comparison:\n\n")
|
||||
|
||||
performSourceComparisons(sources, serviceAvailability)
|
||||
printSourceSummary(sources, serviceAvailability)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// performSourceComparisons compares configured sources with availability
|
||||
func performSourceComparisons(sources *models.Sources, serviceAvailability *models.ServiceAvailability) {
|
||||
// Check key services
|
||||
comparisons := []struct {
|
||||
name string
|
||||
configuredCheck func() bool
|
||||
availableCheck func() bool
|
||||
getConfiguredSources func() []models.SourceItem
|
||||
}{
|
||||
{
|
||||
"Spotify",
|
||||
sources.HasSpotify,
|
||||
serviceAvailability.HasSpotify,
|
||||
sources.GetSpotifySources,
|
||||
},
|
||||
{
|
||||
"Bluetooth",
|
||||
sources.HasBluetooth,
|
||||
serviceAvailability.HasBluetooth,
|
||||
func() []models.SourceItem { return sources.GetSourcesByType("BLUETOOTH") },
|
||||
},
|
||||
}
|
||||
|
||||
for _, comp := range comparisons {
|
||||
compareServiceStatus(comp.name, comp.configuredCheck(), comp.availableCheck(), serviceAvailability)
|
||||
}
|
||||
}
|
||||
|
||||
// compareServiceStatus compares a single service's configuration vs availability
|
||||
func compareServiceStatus(serviceName string, configured, available bool, serviceAvailability *models.ServiceAvailability) {
|
||||
fmt.Printf("🔍 %s:\n", serviceName)
|
||||
fmt.Printf(" Configured: %s\n", boolToStatus(configured))
|
||||
fmt.Printf(" Available: %s\n", boolToStatus(available))
|
||||
|
||||
switch {
|
||||
case available && !configured:
|
||||
fmt.Printf(" 💡 %s is available but not configured - consider setting it up\n", serviceName)
|
||||
case configured && !available:
|
||||
fmt.Printf(" ⚠️ %s is configured but not available - check device status\n", serviceName)
|
||||
printServiceUnavailableReason(serviceName, serviceAvailability)
|
||||
case configured && available:
|
||||
fmt.Printf(" ✅ %s is properly configured and available\n", serviceName)
|
||||
default:
|
||||
fmt.Printf(" ➖ %s is neither configured nor available\n", serviceName)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// printServiceUnavailableReason prints the reason why a service is unavailable
|
||||
func printServiceUnavailableReason(serviceName string, serviceAvailability *models.ServiceAvailability) {
|
||||
var service *models.Service
|
||||
|
||||
switch serviceName {
|
||||
case "Spotify":
|
||||
service = serviceAvailability.GetServiceByType(models.ServiceTypeSpotify)
|
||||
case "Bluetooth":
|
||||
service = serviceAvailability.GetServiceByType(models.ServiceTypeBluetooth)
|
||||
}
|
||||
|
||||
if service != nil && service.Reason != "" {
|
||||
fmt.Printf(" 📝 Reason: %s\n", service.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// printSourceSummary prints a summary of sources and services
|
||||
func printSourceSummary(sources *models.Sources, serviceAvailability *models.ServiceAvailability) {
|
||||
// Summary
|
||||
fmt.Printf("📊 Summary:\n")
|
||||
fmt.Printf(" Total configured sources: %d\n", sources.GetSourceCount())
|
||||
fmt.Printf(" Ready configured sources: %d\n", sources.GetReadySourceCount())
|
||||
fmt.Printf(" Total available services: %d\n", serviceAvailability.GetAvailableServiceCount())
|
||||
fmt.Printf(" Total possible services: %d\n", serviceAvailability.GetServiceCount())
|
||||
}
|
||||
|
||||
// boolToStatus converts boolean to user-friendly status
|
||||
func boolToStatus(b bool) string {
|
||||
if b {
|
||||
return "✅ Yes"
|
||||
}
|
||||
|
||||
return "❌ No"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// playTTS plays a Text-To-Speech message on the speaker
|
||||
func playTTS(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
text := c.String("text")
|
||||
appKey := c.String("app-key")
|
||||
volume := c.Int("volume")
|
||||
language := c.String("language")
|
||||
|
||||
if text == "" {
|
||||
PrintError("Text message is required")
|
||||
return fmt.Errorf("text message cannot be empty")
|
||||
}
|
||||
|
||||
if appKey == "" {
|
||||
PrintError("App key is required")
|
||||
return fmt.Errorf("app key cannot be empty")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Playing TTS message: \"%s\"", text), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Create PlayInfo for TTS
|
||||
var playInfo *models.PlayInfo
|
||||
if volume > 0 {
|
||||
playInfo = models.NewTTSPlayInfo(text, appKey, language, volume)
|
||||
} else {
|
||||
playInfo = models.NewTTSPlayInfo(text, appKey, language)
|
||||
}
|
||||
|
||||
err = client.PlayCustom(playInfo)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to play TTS message: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ TTS message sent successfully\n")
|
||||
|
||||
if volume > 0 {
|
||||
fmt.Printf(" Volume: %d\n", volume)
|
||||
} else {
|
||||
fmt.Printf(" Volume: current level\n")
|
||||
}
|
||||
|
||||
fmt.Printf(" Language: %s\n", strings.ToUpper(language))
|
||||
fmt.Printf(" Message: \"%s\"\n", text)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// playURL plays audio content from a URL on the speaker
|
||||
func playURL(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
urlStr := c.String("url")
|
||||
appKey := c.String("app-key")
|
||||
service := c.String("service")
|
||||
message := c.String("message")
|
||||
reason := c.String("reason")
|
||||
volume := c.Int("volume")
|
||||
|
||||
if urlStr == "" {
|
||||
PrintError("URL is required")
|
||||
return fmt.Errorf("URL cannot be empty")
|
||||
}
|
||||
|
||||
if appKey == "" {
|
||||
PrintError("App key is required")
|
||||
return fmt.Errorf("app key cannot be empty")
|
||||
}
|
||||
|
||||
// Set defaults if not provided
|
||||
if service == "" {
|
||||
service = "URL Playback"
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
message = "Audio Content"
|
||||
}
|
||||
|
||||
if reason == "" {
|
||||
// Extract filename or use URL as reason
|
||||
if idx := strings.LastIndex(urlStr, "/"); idx != -1 && idx < len(urlStr)-1 {
|
||||
reason = urlStr[idx+1:]
|
||||
} else {
|
||||
reason = urlStr
|
||||
}
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Playing URL: %s", urlStr), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Create PlayInfo for URL content
|
||||
var playInfo *models.PlayInfo
|
||||
if volume > 0 {
|
||||
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason, volume)
|
||||
} else {
|
||||
playInfo = models.NewURLPlayInfo(urlStr, appKey, service, message, reason)
|
||||
}
|
||||
|
||||
err = client.PlayCustom(playInfo)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to play URL content: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ URL playback started successfully\n")
|
||||
fmt.Printf(" URL: %s\n", urlStr)
|
||||
fmt.Printf(" Service: %s\n", service)
|
||||
fmt.Printf(" Message: %s\n", message)
|
||||
|
||||
if volume > 0 {
|
||||
fmt.Printf(" Volume: %d\n", volume)
|
||||
} else {
|
||||
fmt.Printf(" Volume: current level\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// playNotification plays a notification sound or a local file on the speaker
|
||||
func playNotification(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
path := c.String("path")
|
||||
|
||||
if path != "" {
|
||||
PrintDeviceHeader(fmt.Sprintf("Playing notification file: %s", path), clientConfig.Host, clientConfig.Port)
|
||||
} else {
|
||||
PrintDeviceHeader("Playing notification beep", clientConfig.Host, clientConfig.Port)
|
||||
}
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.PlayNotification(path)
|
||||
if err != nil {
|
||||
if path != "" {
|
||||
PrintError(fmt.Sprintf("Failed to play notification file: %v", err))
|
||||
} else {
|
||||
PrintError(fmt.Sprintf("Failed to play notification beep: %v", err))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
if path != "" {
|
||||
fmt.Printf("✅ Notification file sent successfully: %s\n", path)
|
||||
} else {
|
||||
fmt.Printf("✅ Notification beep played successfully\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// playNotificationBeep plays a notification beep on the speaker (uses existing endpoint)
|
||||
func playNotificationBeep(c *cli.Context) error {
|
||||
return playNotification(c)
|
||||
}
|
||||
|
||||
// showSpeakerHelp displays help information about speaker functionality
|
||||
func showSpeakerHelp(_ *cli.Context) error {
|
||||
fmt.Println("SoundTouch Speaker Playback Commands")
|
||||
fmt.Println("=====================================")
|
||||
fmt.Println()
|
||||
fmt.Println("The /speaker endpoint supports playing notifications and URL content:")
|
||||
fmt.Println()
|
||||
fmt.Println("• Text-to-Speech (TTS) Messages:")
|
||||
fmt.Println(" Play spoken messages using Google TTS")
|
||||
fmt.Println(" Example: soundtouch-cli speaker tts --text \"Hello World\" --app-key YOUR_KEY")
|
||||
fmt.Println()
|
||||
fmt.Println("• URL Content Playback:")
|
||||
fmt.Println(" Play audio files from HTTP/HTTPS URLs")
|
||||
fmt.Println(" Example: soundtouch-cli speaker url --url \"https://example.com/audio.mp3\" --app-key YOUR_KEY")
|
||||
fmt.Println()
|
||||
fmt.Println("• Notification Beep:")
|
||||
fmt.Println(" Play a simple notification sound")
|
||||
fmt.Println(" Example: soundtouch-cli speaker beep")
|
||||
fmt.Println()
|
||||
fmt.Println("• Custom Notification:")
|
||||
fmt.Println(" Play a device-local PCM file as notification")
|
||||
fmt.Println(" Example: soundtouch-cli speaker notify --path \"/opt/Bose/chimes/grouped.pcm\"")
|
||||
fmt.Println()
|
||||
fmt.Println("Notes:")
|
||||
fmt.Println("• Only ST-10 (Series III) speakers support the /speaker endpoint")
|
||||
fmt.Println("• ST-300 and other models may not support this functionality")
|
||||
fmt.Println("• You need to provide your own app_key for TTS and URL playback")
|
||||
fmt.Println("• Currently playing content is paused during playback and resumed after")
|
||||
fmt.Println("• If device is a zone master, content plays on all zone members")
|
||||
fmt.Println("• Volume is automatically restored after playback completes")
|
||||
fmt.Println()
|
||||
fmt.Println("Supported Languages for TTS:")
|
||||
fmt.Println("EN (English), DE (German), ES (Spanish), FR (French), IT (Italian),")
|
||||
fmt.Println("NL (Dutch), PT (Portuguese), RU (Russian), ZH (Chinese), JA (Japanese)")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// searchStations handles searching for stations across different sources
|
||||
func searchStations(c *cli.Context) error {
|
||||
source := c.String("source")
|
||||
sourceAccount := c.String("source-account")
|
||||
searchTerm := c.String("query")
|
||||
|
||||
if searchTerm == "" {
|
||||
PrintError("Search query is required")
|
||||
return fmt.Errorf("search query cannot be empty")
|
||||
}
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Searching %s for: %s", source, searchTerm), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check service availability for the source
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
|
||||
actionDescription := fmt.Sprintf("search %s stations", source)
|
||||
if !checker.CheckSourceAvailable(source, actionDescription) {
|
||||
return fmt.Errorf("source '%s' is not available for station search", source)
|
||||
}
|
||||
|
||||
response, err := client.SearchStation(source, sourceAccount, searchTerm)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to search stations: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printSearchResults(response, searchTerm)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchTuneIn handles searching TuneIn specifically
|
||||
func searchTuneIn(c *cli.Context) error {
|
||||
searchTerm := c.String("query")
|
||||
|
||||
if searchTerm == "" {
|
||||
PrintError("Search query is required")
|
||||
return fmt.Errorf("search query cannot be empty")
|
||||
}
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Searching TuneIn for: %s", searchTerm), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check TuneIn availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.ValidateTuneInAvailable("search TuneIn stations") {
|
||||
return fmt.Errorf("TuneIn is not available on this device")
|
||||
}
|
||||
|
||||
response, err := client.SearchTuneInStations(searchTerm)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to search TuneIn: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printSearchResults(response, searchTerm)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchPandora handles searching Pandora specifically
|
||||
func searchPandora(c *cli.Context) error {
|
||||
sourceAccount := c.String("source-account")
|
||||
searchTerm := c.String("query")
|
||||
|
||||
if sourceAccount == "" {
|
||||
PrintError("Pandora source account is required")
|
||||
return fmt.Errorf("source account required for Pandora")
|
||||
}
|
||||
|
||||
if searchTerm == "" {
|
||||
PrintError("Search query is required")
|
||||
return fmt.Errorf("search query cannot be empty")
|
||||
}
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Searching Pandora for: %s", searchTerm), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check Pandora availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.ValidatePandoraAvailable("search Pandora stations") {
|
||||
return fmt.Errorf("pandora is not available on this device")
|
||||
}
|
||||
|
||||
response, err := client.SearchPandoraStations(sourceAccount, searchTerm)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to search Pandora: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printSearchResults(response, searchTerm)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// searchSpotify handles searching Spotify specifically
|
||||
func searchSpotify(c *cli.Context) error {
|
||||
sourceAccount := c.String("source-account")
|
||||
searchTerm := c.String("query")
|
||||
|
||||
if sourceAccount == "" {
|
||||
PrintError("Spotify source account is required")
|
||||
return fmt.Errorf("source account required for Spotify")
|
||||
}
|
||||
|
||||
if searchTerm == "" {
|
||||
PrintError("Search query is required")
|
||||
return fmt.Errorf("search query cannot be empty")
|
||||
}
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Searching Spotify for: %s", searchTerm), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check Spotify availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.ValidateSpotifyAvailable("search Spotify content") {
|
||||
return fmt.Errorf("spotify is not available on this device")
|
||||
}
|
||||
|
||||
response, err := client.SearchSpotifyContent(sourceAccount, searchTerm)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to search Spotify: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printSearchResults(response, searchTerm)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addStation handles adding a station and playing it immediately
|
||||
func addStation(c *cli.Context) error {
|
||||
source := c.String("source")
|
||||
sourceAccount := c.String("source-account")
|
||||
token := c.String("token")
|
||||
name := c.String("name")
|
||||
|
||||
if source == "" {
|
||||
PrintError("Source is required")
|
||||
return fmt.Errorf("source cannot be empty")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
PrintError("Station token is required")
|
||||
return fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
PrintError("Station name is required")
|
||||
return fmt.Errorf("name cannot be empty")
|
||||
}
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Adding %s station: %s", source, name), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check service availability for the source
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
|
||||
actionDescription := fmt.Sprintf("add %s station", source)
|
||||
if !checker.CheckSourceAvailable(source, actionDescription) {
|
||||
return fmt.Errorf("source '%s' is not available for adding stations", source)
|
||||
}
|
||||
|
||||
err = client.AddStation(source, sourceAccount, token, name)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to add station: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess(fmt.Sprintf("Added and started playing station: %s", name))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeStation handles removing a station from collections
|
||||
func removeStation(c *cli.Context) error {
|
||||
source := c.String("source")
|
||||
location := c.String("location")
|
||||
itemType := c.String("type")
|
||||
sourceAccount := c.String("source-account")
|
||||
|
||||
if source == "" {
|
||||
PrintError("Source is required")
|
||||
return fmt.Errorf("source cannot be empty")
|
||||
}
|
||||
|
||||
if location == "" {
|
||||
PrintError("Station location is required")
|
||||
return fmt.Errorf("location cannot be empty")
|
||||
}
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Removing %s station", source), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Create content item for the station to remove
|
||||
contentItem := &models.ContentItem{
|
||||
Source: source,
|
||||
Location: location,
|
||||
Type: itemType,
|
||||
SourceAccount: sourceAccount,
|
||||
}
|
||||
|
||||
err = client.RemoveStation(contentItem)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove station: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
PrintSuccess("Station removed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printSearchResults formats and displays search results
|
||||
func printSearchResults(response *models.SearchStationResponse, searchTerm string) {
|
||||
fmt.Printf("Search Results for '%s':\n", searchTerm)
|
||||
|
||||
if response.IsEmpty() {
|
||||
fmt.Printf(" No results found\n")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" Total results: %d\n", response.GetResultCount())
|
||||
|
||||
// Group results by type for better display
|
||||
songs := response.GetSongs()
|
||||
artists := response.GetArtists()
|
||||
stations := response.GetStations()
|
||||
|
||||
printSongs(songs)
|
||||
printArtists(artists)
|
||||
printStations(stations)
|
||||
printSearchHints(response, songs, artists, stations)
|
||||
}
|
||||
|
||||
// printSongs prints song search results
|
||||
func printSongs(songs []models.SearchResult) {
|
||||
if len(songs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n 🎵 Songs (%d):\n", len(songs))
|
||||
|
||||
for i := range songs {
|
||||
song := &songs[i]
|
||||
fmt.Printf(" %d. %s\n", i+1, song.GetDisplayName())
|
||||
|
||||
if song.Artist != "" {
|
||||
fmt.Printf(" Artist: %s\n", song.Artist)
|
||||
}
|
||||
|
||||
if song.Album != "" {
|
||||
fmt.Printf(" Album: %s\n", song.Album)
|
||||
}
|
||||
|
||||
if song.SourceAccount != "" {
|
||||
fmt.Printf(" Account: %s\n", song.SourceAccount)
|
||||
}
|
||||
|
||||
fmt.Printf(" Token: %s\n", song.Token)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printArtists prints artist search results
|
||||
func printArtists(artists []models.SearchResult) {
|
||||
if len(artists) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" 🎤 Artists (%d):\n", len(artists))
|
||||
|
||||
for i := range artists {
|
||||
artist := &artists[i]
|
||||
fmt.Printf(" %d. %s\n", i+1, artist.GetDisplayName())
|
||||
|
||||
if artist.SourceAccount != "" {
|
||||
fmt.Printf(" Account: %s\n", artist.SourceAccount)
|
||||
}
|
||||
|
||||
fmt.Printf(" Token: %s\n", artist.Token)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printStations prints station search results
|
||||
func printStations(stations []models.SearchResult) {
|
||||
if len(stations) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" 📻 Stations (%d):\n", len(stations))
|
||||
|
||||
for i := range stations {
|
||||
station := &stations[i]
|
||||
fmt.Printf(" %d. %s\n", i+1, station.GetDisplayName())
|
||||
|
||||
if station.SourceAccount != "" {
|
||||
fmt.Printf(" Account: %s\n", station.SourceAccount)
|
||||
}
|
||||
|
||||
fmt.Printf(" Token: %s\n", station.Token)
|
||||
|
||||
if station.Description != "" {
|
||||
fmt.Printf(" Description: %s\n", station.Description)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// printSearchHints prints usage hints for search results
|
||||
func printSearchHints(response *models.SearchStationResponse, songs, artists, stations []models.SearchResult) {
|
||||
fmt.Printf("💡 Usage hints:\n")
|
||||
fmt.Printf(" • To add a station and play it: station add --source %s --token <token> --name <name>\n", response.Source)
|
||||
|
||||
if hasAccountResults(response) {
|
||||
fmt.Printf(" • Include --source-account <account> when adding stations that require it\n")
|
||||
}
|
||||
|
||||
if len(songs) > 0 || len(artists) > 0 || len(stations) > 0 {
|
||||
fmt.Printf(" • Copy the token from results above to use with 'station add'\n")
|
||||
}
|
||||
}
|
||||
|
||||
// hasAccountResults checks if any results have source accounts
|
||||
func hasAccountResults(response *models.SearchStationResponse) bool {
|
||||
allResults := response.GetAllResults()
|
||||
for i := range allResults {
|
||||
if allResults[i].SourceAccount != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// listStations handles listing saved stations
|
||||
func listStations(c *cli.Context) error {
|
||||
source := c.String("source")
|
||||
sourceAccount := c.String("source-account")
|
||||
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader(fmt.Sprintf("Getting %s stations", source), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Check service availability for the source
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
|
||||
actionDescription := fmt.Sprintf("list %s stations", source)
|
||||
if !checker.CheckSourceAvailable(source, actionDescription) {
|
||||
return fmt.Errorf("source '%s' is not available for listing stations", source)
|
||||
}
|
||||
|
||||
var response *models.NavigateResponse
|
||||
|
||||
switch strings.ToUpper(source) {
|
||||
case "TUNEIN":
|
||||
response, err = client.GetTuneInStations(sourceAccount)
|
||||
case "PANDORA":
|
||||
if sourceAccount == "" {
|
||||
PrintError("Pandora source account is required")
|
||||
return fmt.Errorf("source account required for Pandora")
|
||||
}
|
||||
|
||||
response, err = client.GetPandoraStations(sourceAccount)
|
||||
default:
|
||||
return fmt.Errorf("listing stations is not supported for source: %s", source)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to get stations: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
printStationList(response, source)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printStationList formats and displays saved station results
|
||||
func printStationList(response *models.NavigateResponse, source string) {
|
||||
fmt.Printf("Saved %s Stations:\n", source)
|
||||
|
||||
if response.TotalItems == 0 {
|
||||
fmt.Printf(" No stations found\n")
|
||||
return
|
||||
}
|
||||
|
||||
stations := response.GetStations()
|
||||
fmt.Printf(" Total stations: %d\n", response.TotalItems)
|
||||
fmt.Printf(" Showing: %d\n\n", len(stations))
|
||||
|
||||
for i, station := range stations {
|
||||
fmt.Printf(" %d. %s\n", i+1, station.Name)
|
||||
|
||||
if station.ContentItem != nil {
|
||||
if station.ContentItem.Location != "" {
|
||||
fmt.Printf(" Location: %s\n", station.ContentItem.Location)
|
||||
}
|
||||
|
||||
if station.ContentItem.SourceAccount != "" {
|
||||
fmt.Printf(" Account: %s\n", station.ContentItem.SourceAccount)
|
||||
}
|
||||
|
||||
if station.ContentItem.IsPresetable {
|
||||
fmt.Printf(" Can be saved as preset: Yes\n")
|
||||
}
|
||||
}
|
||||
|
||||
if station.Type != "" {
|
||||
fmt.Printf(" Type: %s\n", station.Type)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// Show usage hints
|
||||
fmt.Printf("💡 Usage hints:\n")
|
||||
fmt.Printf(" • To play a station: Use the location value with 'play content' command\n")
|
||||
fmt.Printf(" • To save as preset: Use 'preset set' command with the location\n")
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// requestToken requests a new bearer token from the device
|
||||
func requestToken(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
PrintDeviceHeader("Requesting bearer token", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
token, err := client.RequestToken()
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to request token: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Bearer Token Information:")
|
||||
|
||||
if token.IsValid() {
|
||||
fmt.Printf(" Status: Valid\n")
|
||||
fmt.Printf(" Token: %s\n", token.String())
|
||||
fmt.Printf(" Full value: %s\n", token.GetToken())
|
||||
fmt.Printf(" Authorization header: %s\n", token.GetAuthHeader())
|
||||
|
||||
// Display token without Bearer prefix for API usage
|
||||
fmt.Println("\nFor API Usage:")
|
||||
fmt.Printf(" Raw token: %s\n", token.GetTokenWithoutPrefix())
|
||||
|
||||
// Usage instructions
|
||||
fmt.Println("\nUsage Instructions:")
|
||||
fmt.Println(" • Use the 'Authorization header' value in HTTP Authorization headers")
|
||||
fmt.Println(" • Use the 'Raw token' value when an API requires token without 'Bearer ' prefix")
|
||||
fmt.Println(" • Tokens are generated per request and may have expiration times")
|
||||
|
||||
// Security notice
|
||||
fmt.Println("\nSecurity Notice:")
|
||||
fmt.Println(" • Store tokens securely and avoid logging them in plain text")
|
||||
fmt.Println(" • Tokens provide authentication - treat them as passwords")
|
||||
fmt.Println(" • Request new tokens when needed rather than reusing old ones")
|
||||
} else {
|
||||
fmt.Printf(" Status: Invalid\n")
|
||||
fmt.Printf(" Raw response: %s\n", token.GetToken())
|
||||
PrintError("Received invalid bearer token from device")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// addZoneSlave adds a device to an existing zone using the official /addZoneSlave endpoint
|
||||
func addZoneSlave(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
masterID := c.String("master")
|
||||
slaveID := c.String("slave")
|
||||
slaveIP := c.String("slave-ip")
|
||||
|
||||
if masterID == "" {
|
||||
return fmt.Errorf("master device ID is required (use --master)")
|
||||
}
|
||||
|
||||
if slaveID == "" {
|
||||
return fmt.Errorf("slave device ID is required (use --slave)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Adding slave '%s' to zone master '%s'", slaveID, masterID), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if slaveIP != "" {
|
||||
err = client.AddZoneSlave(masterID, slaveID, slaveIP)
|
||||
} else {
|
||||
err = client.AddZoneSlaveByDeviceID(masterID, slaveID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to add zone slave: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Successfully added device '%s' to zone master '%s'\n", slaveID, masterID)
|
||||
|
||||
if slaveIP != "" {
|
||||
fmt.Printf(" Slave IP: %s\n", slaveIP)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeZoneSlave removes a device from an existing zone using the official /removeZoneSlave endpoint
|
||||
func removeZoneSlave(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
masterID := c.String("master")
|
||||
slaveID := c.String("slave")
|
||||
slaveIP := c.String("slave-ip")
|
||||
|
||||
if masterID == "" {
|
||||
return fmt.Errorf("master device ID is required (use --master)")
|
||||
}
|
||||
|
||||
if slaveID == "" {
|
||||
return fmt.Errorf("slave device ID is required (use --slave)")
|
||||
}
|
||||
|
||||
PrintDeviceHeader(fmt.Sprintf("Removing slave '%s' from zone master '%s'", slaveID, masterID), clientConfig.Host, clientConfig.Port)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to create client: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
if slaveIP != "" {
|
||||
err = client.RemoveZoneSlave(masterID, slaveID, slaveIP)
|
||||
} else {
|
||||
err = client.RemoveZoneSlaveByDeviceID(masterID, slaveID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
PrintError(fmt.Sprintf("Failed to remove zone slave: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Successfully removed device '%s' from zone master '%s'\n", slaveID, masterID)
|
||||
|
||||
if slaveIP != "" {
|
||||
fmt.Printf(" Slave IP: %s\n", slaveIP)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -132,6 +139,182 @@ func PrintDeviceHeader(operation, host string, port int) {
|
||||
fmt.Printf("%s from %s:%d...\n", operation, host, port)
|
||||
}
|
||||
|
||||
// resolveLocation converts potential URLs to SoundTouch locations
|
||||
func resolveLocation(source, location string) (string, string) {
|
||||
// If it's not a URL, return as is
|
||||
if !strings.HasPrefix(location, "http://") && !strings.HasPrefix(location, "https://") {
|
||||
return source, location
|
||||
}
|
||||
|
||||
// TuneIn URL conversion
|
||||
// Example: https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/
|
||||
if strings.Contains(location, "tunein.com/radio/") {
|
||||
trimmed := strings.TrimSuffix(location, "/")
|
||||
|
||||
parts := strings.Split(trimmed, "-")
|
||||
if len(parts) > 0 {
|
||||
lastPart := parts[len(parts)-1]
|
||||
if strings.HasPrefix(lastPart, "s") {
|
||||
return "TUNEIN", "/v1/playback/station/" + lastPart
|
||||
}
|
||||
}
|
||||
// Fallback for URLs like https://tunein.com/radio/s213886/
|
||||
parts = strings.Split(trimmed, "/")
|
||||
|
||||
lastPart := parts[len(parts)-1]
|
||||
if strings.HasPrefix(lastPart, "s") {
|
||||
return "TUNEIN", "/v1/playback/station/" + lastPart
|
||||
}
|
||||
}
|
||||
|
||||
// Spotify URL conversion
|
||||
// Example: https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD?si=YhDPWL9LRGO5whz1wLsteA
|
||||
if strings.Contains(location, "open.spotify.com/") {
|
||||
re := regexp.MustCompile(`https://open\.spotify\.com/([^/]+)/([^?]+)`)
|
||||
|
||||
matches := re.FindStringSubmatch(location)
|
||||
if len(matches) >= 3 {
|
||||
contentType := matches[1]
|
||||
contentID := matches[2]
|
||||
uri := fmt.Sprintf("spotify:%s:%s", contentType, contentID)
|
||||
encodedURI := base64.StdEncoding.EncodeToString([]byte(uri))
|
||||
|
||||
return "SPOTIFY", "/playback/container/" + encodedURI
|
||||
}
|
||||
}
|
||||
|
||||
return source, location
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
Name string
|
||||
Artwork string
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
func fetchTuneInMetadata(url string) (*Metadata, error) {
|
||||
if !strings.Contains(url, "tunein.com/radio/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
|
||||
return nil, fmt.Errorf("url is not a TuneIn radio URL")
|
||||
}
|
||||
|
||||
resp, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100)) // Limit to 100KB
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawHTML := string(body)
|
||||
metadata := &Metadata{}
|
||||
|
||||
// Simple extraction of og:title and og:image
|
||||
// Example: <meta data-react-helmet="true" property="og:title" content="WDR 2 Rheinland, 100.4 FM, Köln | Free Internet Radio | TuneIn"/>
|
||||
// Example: <meta data-react-helmet="true" property="og:image" content="https://cdn-radiotime-logos.tunein.com/s213886g.png"/>
|
||||
|
||||
titlePrefix := `property="og:title" content="`
|
||||
if idx := strings.Index(rawHTML, titlePrefix); idx != -1 {
|
||||
start := idx + len(titlePrefix)
|
||||
|
||||
end := strings.Index(rawHTML[start:], `"`)
|
||||
if end != -1 {
|
||||
title := html.UnescapeString(rawHTML[start : start+end])
|
||||
// Clean up title (remove ", 100.4 FM, Köln | Free Internet Radio | TuneIn")
|
||||
if pipeIdx := strings.Index(title, " | "); pipeIdx != -1 {
|
||||
title = title[:pipeIdx]
|
||||
}
|
||||
|
||||
if commaIdx := strings.Index(title, ", "); commaIdx != -1 {
|
||||
title = title[:commaIdx]
|
||||
}
|
||||
|
||||
metadata.Name = title
|
||||
}
|
||||
}
|
||||
|
||||
imagePrefix := `property="og:image" content="`
|
||||
if idx := strings.Index(rawHTML, imagePrefix); idx != -1 {
|
||||
start := idx + len(imagePrefix)
|
||||
|
||||
end := strings.Index(rawHTML[start:], `"`)
|
||||
if end != -1 {
|
||||
metadata.Artwork = rawHTML[start : start+end]
|
||||
}
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func fetchSpotifyMetadata(url string) (*Metadata, error) {
|
||||
if !strings.Contains(url, "open.spotify.com/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
|
||||
return nil, fmt.Errorf("url is not a Spotify URL")
|
||||
}
|
||||
|
||||
resp, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*200)) // Spotify pages can be larger
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawHTML := string(body)
|
||||
metadata := &Metadata{}
|
||||
|
||||
// Simple extraction of og:title and og:image
|
||||
// Example: <meta property="og:title" content="Terminal Caribe - Album by Santi & Tuğçe | Spotify"
|
||||
|
||||
titlePrefix := `property="og:title" content="`
|
||||
if idx := strings.Index(rawHTML, titlePrefix); idx != -1 {
|
||||
start := idx + len(titlePrefix)
|
||||
|
||||
end := strings.Index(rawHTML[start:], `"`)
|
||||
if end != -1 {
|
||||
title := html.UnescapeString(rawHTML[start : start+end])
|
||||
// Clean up title (remove " | Spotify")
|
||||
if pipeIdx := strings.Index(title, " | "); pipeIdx != -1 {
|
||||
title = title[:pipeIdx]
|
||||
}
|
||||
|
||||
// Spotify often has "- Album by ..." or "- Playlist by ..."
|
||||
// We might want to keep it or clean it up.
|
||||
// User's TuneIn example cleaned it up.
|
||||
// For now let's just keep what Spotify provides as title minus the " | Spotify" part.
|
||||
|
||||
metadata.Name = title
|
||||
}
|
||||
}
|
||||
|
||||
imagePrefix := `property="og:image" content="`
|
||||
if idx := strings.Index(rawHTML, imagePrefix); idx != -1 {
|
||||
start := idx + len(imagePrefix)
|
||||
|
||||
end := strings.Index(rawHTML[start:], `"`)
|
||||
if end != -1 {
|
||||
metadata.Artwork = rawHTML[start : start+end]
|
||||
}
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
// PrintSuccess prints a standard success message
|
||||
func PrintSuccess(message string) {
|
||||
fmt.Printf("✓ %s\n", message)
|
||||
@@ -146,3 +329,14 @@ func PrintError(message string) {
|
||||
func PrintWarning(message string) {
|
||||
fmt.Printf("⚠️ %s\n", message)
|
||||
}
|
||||
|
||||
// showVersionInfo displays detailed version information including build details
|
||||
func showVersionInfo(_ *cli.Context) error {
|
||||
fmt.Printf("%s version %s\n", os.Args[0], version)
|
||||
fmt.Printf("Build commit: %s\n", commit)
|
||||
fmt.Printf("Build date: %s\n", date)
|
||||
fmt.Printf("Go version: %s\n", runtime.Version())
|
||||
fmt.Printf("Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchTuneInMetadata(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
html := `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:title" content="WDR 2 Rheinland, 100.4 FM, Köln | Free Internet Radio | TuneIn"/>
|
||||
<meta property="og:image" content="https://cdn-radiotime-logos.tunein.com/s213886g.png"/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
`
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(html))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Temporarily override httpClient to use test server
|
||||
oldClient := httpClient
|
||||
httpClient = ts.Client()
|
||||
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
metadata, err := fetchTuneInMetadata(ts.URL + "/radio/WDR-2-Rheinland-1004-s213886/")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchTuneInMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
t.Fatal("fetchTuneInMetadata() returned nil metadata")
|
||||
} else {
|
||||
expectedName := "WDR 2 Rheinland"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedArtwork := "https://cdn-radiotime-logos.tunein.com/s213886g.png"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
location string
|
||||
expectedSource string
|
||||
expectedLocation string
|
||||
}{
|
||||
{
|
||||
name: "Plain location",
|
||||
source: "TUNEIN",
|
||||
location: "/v1/playback/station/s213886",
|
||||
expectedSource: "TUNEIN",
|
||||
expectedLocation: "/v1/playback/station/s213886",
|
||||
},
|
||||
{
|
||||
name: "TuneIn URL",
|
||||
source: "",
|
||||
location: "https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/",
|
||||
expectedSource: "TUNEIN",
|
||||
expectedLocation: "/v1/playback/station/s213886",
|
||||
},
|
||||
{
|
||||
name: "TuneIn URL with source",
|
||||
source: "SOMETHING",
|
||||
location: "https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/",
|
||||
expectedSource: "TUNEIN",
|
||||
expectedLocation: "/v1/playback/station/s213886",
|
||||
},
|
||||
{
|
||||
name: "TuneIn URL without trailing slash",
|
||||
source: "",
|
||||
location: "https://tunein.com/radio/WDR-2-Rheinland-1004-s213886",
|
||||
expectedSource: "TUNEIN",
|
||||
expectedLocation: "/v1/playback/station/s213886",
|
||||
},
|
||||
{
|
||||
name: "Non-TuneIn URL",
|
||||
source: "OTHER",
|
||||
location: "https://example.com/radio/s123",
|
||||
expectedSource: "OTHER",
|
||||
expectedLocation: "https://example.com/radio/s123",
|
||||
},
|
||||
{
|
||||
name: "TuneIn URL short form",
|
||||
source: "",
|
||||
location: "https://tunein.com/radio/s213886/",
|
||||
expectedSource: "TUNEIN",
|
||||
expectedLocation: "/v1/playback/station/s213886",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotSource, gotLocation := resolveLocation(tt.source, tt.location)
|
||||
if gotSource != tt.expectedSource {
|
||||
t.Errorf("resolveLocation() gotSource = %v, want %v", gotSource, tt.expectedSource)
|
||||
}
|
||||
|
||||
if gotLocation != tt.expectedLocation {
|
||||
t.Errorf("resolveLocation() gotLocation = %v, want %v", gotLocation, tt.expectedLocation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLocationSpotify(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
location string
|
||||
expectedSource string
|
||||
expectedLocation string
|
||||
}{
|
||||
{
|
||||
name: "Spotify album URL",
|
||||
source: "",
|
||||
location: "https://open.spotify.com/album/6rT8yer84xoh0t17poLsmn?si=XqxdZazpTLC1ceoC8EeCuA",
|
||||
expectedSource: "SPOTIFY",
|
||||
expectedLocation: "/playback/container/c3BvdGlmeTphbGJ1bTo2clQ4eWVyODR4b2gwdDE3cG9Mc21u",
|
||||
},
|
||||
{
|
||||
name: "Spotify playlist URL",
|
||||
source: "",
|
||||
location: "https://open.spotify.com/playlist/37i9dQZF1DX0XUsuxWHRQd",
|
||||
expectedSource: "SPOTIFY",
|
||||
expectedLocation: "/playback/container/c3BvdGlmeTpwbGF5bGlzdDozN2k5ZFFaRjFEWDBYVXN1eFdIUlFk",
|
||||
},
|
||||
{
|
||||
name: "Spotify track URL",
|
||||
source: "",
|
||||
location: "https://open.spotify.com/track/17GmwQ9Q3MTAz05OokmNNB?si=123",
|
||||
expectedSource: "SPOTIFY",
|
||||
expectedLocation: "/playback/container/c3BvdGlmeTp0cmFjazoxN0dtd1E5UTNNVEF6MDVPb2ttTk5C",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotSource, gotLocation := resolveLocation(tt.source, tt.location)
|
||||
if gotSource != tt.expectedSource {
|
||||
t.Errorf("resolveLocation() gotSource = %v, want %v", gotSource, tt.expectedSource)
|
||||
}
|
||||
|
||||
if gotLocation != tt.expectedLocation {
|
||||
t.Errorf("resolveLocation() gotLocation = %v, want %v", gotLocation, tt.expectedLocation)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSpotifyMetadata(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
html := `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:title" content="Terminal Caribe - Album by Santi & Tuğçe | Spotify"/>
|
||||
<meta property="og:image" content="https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
`
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(html))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Temporarily override httpClient to use test server
|
||||
oldClient := httpClient
|
||||
httpClient = ts.Client()
|
||||
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
metadata, err := fetchSpotifyMetadata(ts.URL + "/album/7F50uh7oGitmAEScRKV6pD")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchSpotifyMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
t.Fatal("fetchSpotifyMetadata() returned nil metadata")
|
||||
} else {
|
||||
expectedName := "Terminal Caribe - Album by Santi & Tuğçe"
|
||||
if metadata.Name != expectedName {
|
||||
t.Errorf("metadata.Name = %v, want %v", metadata.Name, expectedName)
|
||||
}
|
||||
|
||||
expectedArtwork := "https://i.scdn.co/image/ab67616d0000b273f0e55478f4a15182405bcb47"
|
||||
if metadata.Artwork != expectedArtwork {
|
||||
t.Errorf("metadata.Artwork = %v, want %v", metadata.Artwork, expectedArtwork)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1567
-15
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,390 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// ServiceAvailabilityChecker provides service availability validation for CLI commands
|
||||
type ServiceAvailabilityChecker struct {
|
||||
client *client.Client
|
||||
serviceAvailability *models.ServiceAvailability
|
||||
skipAvailabilityCheck bool
|
||||
cached bool
|
||||
}
|
||||
|
||||
// NewServiceAvailabilityChecker creates a new service availability checker
|
||||
func NewServiceAvailabilityChecker(client *client.Client) *ServiceAvailabilityChecker {
|
||||
skipCheck := os.Getenv("SOUNDTOUCH_SKIP_AVAILABILITY_CHECK") == "true" ||
|
||||
os.Getenv("SOUNDTOUCH_SKIP_AVAILABILITY_CHECK") == "1"
|
||||
|
||||
return &ServiceAvailabilityChecker{
|
||||
client: client,
|
||||
skipAvailabilityCheck: skipCheck,
|
||||
cached: false,
|
||||
}
|
||||
}
|
||||
|
||||
// loadServiceAvailability loads service availability data (cached after first call)
|
||||
func (sac *ServiceAvailabilityChecker) loadServiceAvailability() {
|
||||
if sac.cached {
|
||||
return
|
||||
}
|
||||
|
||||
if sac.skipAvailabilityCheck {
|
||||
// Create a mock availability that allows everything
|
||||
sac.serviceAvailability = &models.ServiceAvailability{}
|
||||
sac.cached = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
serviceAvailability, err := sac.client.GetServiceAvailability()
|
||||
if err != nil {
|
||||
// If availability check fails, warn but don't fail the command
|
||||
PrintWarning(fmt.Sprintf("Could not check service availability: %v", err))
|
||||
|
||||
if !sac.skipAvailabilityCheck {
|
||||
PrintWarning("Command will proceed without availability validation")
|
||||
PrintWarning("Set SOUNDTOUCH_SKIP_AVAILABILITY_CHECK=true to disable these checks")
|
||||
}
|
||||
// Create empty availability to prevent further errors
|
||||
sac.serviceAvailability = &models.ServiceAvailability{}
|
||||
sac.cached = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sac.serviceAvailability = serviceAvailability
|
||||
sac.cached = true
|
||||
}
|
||||
|
||||
// CheckServiceAvailable validates if a service is available and provides user feedback
|
||||
func (sac *ServiceAvailabilityChecker) CheckServiceAvailable(serviceType models.ServiceType, actionDescription string) bool {
|
||||
if sac.skipAvailabilityCheck {
|
||||
return true
|
||||
}
|
||||
|
||||
sac.loadServiceAvailability()
|
||||
|
||||
// If we couldn't load availability data, allow the operation
|
||||
if sac.serviceAvailability == nil || sac.serviceAvailability.Services == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if sac.serviceAvailability.IsServiceAvailable(serviceType) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Service is not available - provide helpful feedback
|
||||
serviceName := formatServiceTypeForDisplay(serviceType)
|
||||
PrintError(fmt.Sprintf("Cannot %s: %s service is not available", actionDescription, serviceName))
|
||||
|
||||
// Get specific reason if available
|
||||
service := sac.serviceAvailability.GetServiceByType(serviceType)
|
||||
if service != nil && service.Reason != "" {
|
||||
PrintError(fmt.Sprintf("Reason: %s", service.Reason))
|
||||
}
|
||||
|
||||
// Provide troubleshooting hints
|
||||
sac.provideTroubleshootingHints(serviceType)
|
||||
|
||||
// Suggest alternatives
|
||||
sac.suggestAlternatives(serviceType, actionDescription)
|
||||
|
||||
PrintWarning("To bypass this check, set SOUNDTOUCH_SKIP_AVAILABILITY_CHECK=true")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckSourceAvailable validates if a source string corresponds to an available service
|
||||
func (sac *ServiceAvailabilityChecker) CheckSourceAvailable(source, actionDescription string) bool {
|
||||
if sac.skipAvailabilityCheck {
|
||||
return true
|
||||
}
|
||||
|
||||
serviceType := sourceToServiceType(source)
|
||||
if serviceType == "" {
|
||||
// Unknown source type, allow it (might be a valid source not in our list)
|
||||
return true
|
||||
}
|
||||
|
||||
return sac.CheckServiceAvailable(serviceType, actionDescription)
|
||||
}
|
||||
|
||||
// ValidateSpotifyAvailable checks Spotify availability for Spotify-specific operations
|
||||
func (sac *ServiceAvailabilityChecker) ValidateSpotifyAvailable(actionDescription string) bool {
|
||||
return sac.CheckServiceAvailable(models.ServiceTypeSpotify, actionDescription)
|
||||
}
|
||||
|
||||
// ValidateBluetoothAvailable checks Bluetooth availability for Bluetooth operations
|
||||
func (sac *ServiceAvailabilityChecker) ValidateBluetoothAvailable(actionDescription string) bool {
|
||||
return sac.CheckServiceAvailable(models.ServiceTypeBluetooth, actionDescription)
|
||||
}
|
||||
|
||||
// ValidateTuneInAvailable checks TuneIn availability for radio operations
|
||||
func (sac *ServiceAvailabilityChecker) ValidateTuneInAvailable(actionDescription string) bool {
|
||||
return sac.CheckServiceAvailable(models.ServiceTypeTuneIn, actionDescription)
|
||||
}
|
||||
|
||||
// ValidatePandoraAvailable checks Pandora availability for Pandora operations
|
||||
func (sac *ServiceAvailabilityChecker) ValidatePandoraAvailable(actionDescription string) bool {
|
||||
return sac.CheckServiceAvailable(models.ServiceTypePandora, actionDescription)
|
||||
}
|
||||
|
||||
// GetAvailableStreamingServices returns a list of available streaming services for user feedback
|
||||
func (sac *ServiceAvailabilityChecker) GetAvailableStreamingServices() []string {
|
||||
if sac.skipAvailabilityCheck {
|
||||
return []string{"All services (availability check disabled)"}
|
||||
}
|
||||
|
||||
sac.loadServiceAvailability()
|
||||
|
||||
if sac.serviceAvailability == nil || sac.serviceAvailability.Services == nil {
|
||||
return []string{"Unable to determine available services"}
|
||||
}
|
||||
|
||||
streamingServices := sac.serviceAvailability.GetStreamingServices()
|
||||
|
||||
var available []string
|
||||
|
||||
for _, service := range streamingServices {
|
||||
if service.IsAvailable {
|
||||
available = append(available, formatServiceTypeForDisplay(models.ServiceType(service.Type)))
|
||||
}
|
||||
}
|
||||
|
||||
if len(available) == 0 {
|
||||
return []string{"No streaming services currently available"}
|
||||
}
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
// GetAvailableLocalServices returns a list of available local input services
|
||||
func (sac *ServiceAvailabilityChecker) GetAvailableLocalServices() []string {
|
||||
if sac.skipAvailabilityCheck {
|
||||
return []string{"All services (availability check disabled)"}
|
||||
}
|
||||
|
||||
sac.loadServiceAvailability()
|
||||
|
||||
if sac.serviceAvailability == nil || sac.serviceAvailability.Services == nil {
|
||||
return []string{"Unable to determine available services"}
|
||||
}
|
||||
|
||||
localServices := sac.serviceAvailability.GetLocalServices()
|
||||
|
||||
var available []string
|
||||
|
||||
for _, service := range localServices {
|
||||
if service.IsAvailable {
|
||||
available = append(available, formatServiceTypeForDisplay(models.ServiceType(service.Type)))
|
||||
}
|
||||
}
|
||||
|
||||
if len(available) == 0 {
|
||||
return []string{"No local input services currently available"}
|
||||
}
|
||||
|
||||
return available
|
||||
}
|
||||
|
||||
// provideTroubleshootingHints provides specific troubleshooting advice based on service type
|
||||
func (sac *ServiceAvailabilityChecker) provideTroubleshootingHints(serviceType models.ServiceType) {
|
||||
switch serviceType {
|
||||
case models.ServiceTypeBluetooth:
|
||||
PrintWarning("💡 Bluetooth troubleshooting:")
|
||||
PrintWarning(" • Check if your device supports Bluetooth audio input")
|
||||
PrintWarning(" • Ensure Bluetooth is enabled on the SoundTouch device")
|
||||
PrintWarning(" • Try restarting the device")
|
||||
|
||||
case models.ServiceTypeSpotify:
|
||||
PrintWarning("💡 Spotify troubleshooting:")
|
||||
PrintWarning(" • Ensure you have a Spotify Premium account")
|
||||
PrintWarning(" • Check if you're logged in to Spotify on the device")
|
||||
PrintWarning(" • Verify your network connection")
|
||||
|
||||
case models.ServiceTypeAirPlay:
|
||||
PrintWarning("💡 AirPlay troubleshooting:")
|
||||
PrintWarning(" • Ensure your Apple device and SoundTouch are on the same network")
|
||||
PrintWarning(" • Check that AirPlay is enabled in device settings")
|
||||
PrintWarning(" • Verify network connectivity")
|
||||
|
||||
case models.ServiceTypeAlexa:
|
||||
PrintWarning("💡 Alexa troubleshooting:")
|
||||
PrintWarning(" • Check if Amazon Alexa is properly configured")
|
||||
PrintWarning(" • Ensure the device is connected to your Amazon account")
|
||||
PrintWarning(" • Verify internet connectivity")
|
||||
|
||||
case models.ServiceTypeTuneIn:
|
||||
PrintWarning("💡 TuneIn troubleshooting:")
|
||||
PrintWarning(" • Check internet connectivity")
|
||||
PrintWarning(" • Verify the device can access external streaming services")
|
||||
|
||||
case models.ServiceTypePandora:
|
||||
PrintWarning("💡 Pandora troubleshooting:")
|
||||
PrintWarning(" • Ensure you have a valid Pandora account")
|
||||
PrintWarning(" • Check if you're logged in to Pandora on the device")
|
||||
PrintWarning(" • Verify internet connectivity")
|
||||
}
|
||||
}
|
||||
|
||||
// suggestAlternatives suggests alternative services when the requested one is unavailable
|
||||
func (sac *ServiceAvailabilityChecker) suggestAlternatives(serviceType models.ServiceType, _ string) {
|
||||
if sac.serviceAvailability == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch serviceType {
|
||||
case models.ServiceTypeSpotify:
|
||||
if sac.serviceAvailability.HasTuneIn() {
|
||||
PrintWarning("💡 Alternative: TuneIn Radio is available for music streaming")
|
||||
}
|
||||
|
||||
if sac.serviceAvailability.HasPandora() {
|
||||
PrintWarning("💡 Alternative: Pandora is available for music streaming")
|
||||
}
|
||||
|
||||
case models.ServiceTypeBluetooth:
|
||||
if sac.serviceAvailability.HasAirPlay() {
|
||||
PrintWarning("💡 Alternative: AirPlay is available for wireless audio")
|
||||
}
|
||||
|
||||
if sac.serviceAvailability.HasLocalMusic() {
|
||||
PrintWarning("💡 Alternative: Local Music Library is available")
|
||||
}
|
||||
|
||||
case models.ServiceTypeTuneIn:
|
||||
if sac.serviceAvailability.HasSpotify() {
|
||||
PrintWarning("💡 Alternative: Spotify is available for music streaming")
|
||||
}
|
||||
|
||||
if sac.serviceAvailability.HasPandora() {
|
||||
PrintWarning("💡 Alternative: Pandora is available for music streaming")
|
||||
}
|
||||
}
|
||||
|
||||
// Show all available streaming services as suggestions
|
||||
available := sac.GetAvailableStreamingServices()
|
||||
if len(available) > 0 && available[0] != "No streaming services currently available" {
|
||||
PrintWarning(fmt.Sprintf("💡 Available streaming services: %s", strings.Join(available, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// sourceToServiceType maps source strings to service types
|
||||
func sourceToServiceType(source string) models.ServiceType {
|
||||
switch strings.ToUpper(source) {
|
||||
case "SPOTIFY":
|
||||
return models.ServiceTypeSpotify
|
||||
case "BLUETOOTH":
|
||||
return models.ServiceTypeBluetooth
|
||||
case "AIRPLAY":
|
||||
return models.ServiceTypeAirPlay
|
||||
case "ALEXA":
|
||||
return models.ServiceTypeAlexa
|
||||
case "AMAZON":
|
||||
return models.ServiceTypeAmazon
|
||||
case "PANDORA":
|
||||
return models.ServiceTypePandora
|
||||
case "TUNEIN":
|
||||
return models.ServiceTypeTuneIn
|
||||
case "DEEZER":
|
||||
return models.ServiceTypeDeezer
|
||||
case "IHEART", "IHEARTRADIO":
|
||||
return models.ServiceTypeIHeart
|
||||
case "LOCAL_INTERNET_RADIO":
|
||||
return models.ServiceTypeLocalInternetRadio
|
||||
case "LOCAL_MUSIC":
|
||||
return models.ServiceTypeLocalMusic
|
||||
case "BMX":
|
||||
return models.ServiceTypeBMX
|
||||
case "NOTIFICATION":
|
||||
return models.ServiceTypeNotification
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// formatServiceTypeForDisplay formats service types for user-friendly display
|
||||
func formatServiceTypeForDisplay(serviceType models.ServiceType) string {
|
||||
switch serviceType {
|
||||
case models.ServiceTypeSpotify:
|
||||
return "Spotify"
|
||||
case models.ServiceTypeBluetooth:
|
||||
return "Bluetooth"
|
||||
case models.ServiceTypeAirPlay:
|
||||
return "AirPlay"
|
||||
case models.ServiceTypeAlexa:
|
||||
return "Amazon Alexa"
|
||||
case models.ServiceTypeAmazon:
|
||||
return "Amazon Music"
|
||||
case models.ServiceTypePandora:
|
||||
return "Pandora"
|
||||
case models.ServiceTypeTuneIn:
|
||||
return "TuneIn Radio"
|
||||
case models.ServiceTypeDeezer:
|
||||
return "Deezer"
|
||||
case models.ServiceTypeIHeart:
|
||||
return "iHeartRadio"
|
||||
case models.ServiceTypeLocalInternetRadio:
|
||||
return "Internet Radio"
|
||||
case models.ServiceTypeLocalMusic:
|
||||
return "Local Music Library"
|
||||
case models.ServiceTypeBMX:
|
||||
return "BMX"
|
||||
case models.ServiceTypeNotification:
|
||||
return "Notifications"
|
||||
default:
|
||||
return string(serviceType)
|
||||
}
|
||||
}
|
||||
|
||||
// PrintServiceAvailabilitySummary prints a summary of available services
|
||||
func (sac *ServiceAvailabilityChecker) PrintServiceAvailabilitySummary() {
|
||||
if sac.skipAvailabilityCheck {
|
||||
PrintWarning("Service availability checking is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
sac.loadServiceAvailability()
|
||||
|
||||
if sac.serviceAvailability == nil || sac.serviceAvailability.Services == nil {
|
||||
PrintWarning("Unable to determine service availability")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("📊 Service Availability Summary:\n")
|
||||
fmt.Printf(" Total services: %d\n", sac.serviceAvailability.GetServiceCount())
|
||||
fmt.Printf(" Available: %d\n", sac.serviceAvailability.GetAvailableServiceCount())
|
||||
fmt.Printf(" Unavailable: %d\n", sac.serviceAvailability.GetUnavailableServiceCount())
|
||||
|
||||
// Show quick status for popular services
|
||||
fmt.Printf(" Popular services:\n")
|
||||
|
||||
popularChecks := []struct {
|
||||
check func() bool
|
||||
name string
|
||||
}{
|
||||
{sac.serviceAvailability.HasSpotify, "Spotify"},
|
||||
{sac.serviceAvailability.HasBluetooth, "Bluetooth"},
|
||||
{sac.serviceAvailability.HasAirPlay, "AirPlay"},
|
||||
{sac.serviceAvailability.HasTuneIn, "TuneIn Radio"},
|
||||
{sac.serviceAvailability.HasPandora, "Pandora"},
|
||||
}
|
||||
|
||||
for _, check := range popularChecks {
|
||||
status := "❌"
|
||||
if check.check() {
|
||||
status = "✅"
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", status, check.name)
|
||||
}
|
||||
|
||||
fmt.Printf("💡 Use 'soundtouch-cli sources list' to see configured sources\n")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func TestApplyPersistedSettings(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "main-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := datastore.NewDataStore(tmpDir)
|
||||
|
||||
t.Run("overrides true with false", func(t *testing.T) {
|
||||
config := &serviceConfig{
|
||||
redact: true,
|
||||
logBody: true,
|
||||
record: true,
|
||||
}
|
||||
|
||||
// Simulate the bug by using the old bitwise OR logic in the test,
|
||||
// which should fail if we expect false.
|
||||
// config.redact = config.redact || false -> stays true
|
||||
|
||||
settings := datastore.Settings{
|
||||
RedactLogs: false,
|
||||
LogBodies: false,
|
||||
RecordInteractions: false,
|
||||
}
|
||||
err := ds.SaveSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
applyPersistedSettings(ds, config)
|
||||
|
||||
if config.redact != false {
|
||||
t.Errorf("Expected redact to be false, got true")
|
||||
}
|
||||
if config.logBody != false {
|
||||
t.Errorf("Expected logBody to be false, got true")
|
||||
}
|
||||
if config.record != false {
|
||||
t.Errorf("Expected record to be false, got true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retains false when settings are false", func(t *testing.T) {
|
||||
settings := datastore.Settings{
|
||||
RedactLogs: false,
|
||||
}
|
||||
err := ds.SaveSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
config := &serviceConfig{
|
||||
redact: false,
|
||||
}
|
||||
|
||||
applyPersistedSettings(ds, config)
|
||||
|
||||
if config.redact != false {
|
||||
t.Errorf("Expected redact to be false, got true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("overrides false with true", func(t *testing.T) {
|
||||
settings := datastore.Settings{
|
||||
RedactLogs: true,
|
||||
}
|
||||
err := ds.SaveSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to save settings: %v", err)
|
||||
}
|
||||
|
||||
config := &serviceConfig{
|
||||
redact: false,
|
||||
}
|
||||
|
||||
applyPersistedSettings(ds, config)
|
||||
|
||||
if config.redact != true {
|
||||
t.Errorf("Expected redact to be true, got false")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func TestPrintRoutes(t *testing.T) {
|
||||
// Initialize a minimal server to get the router
|
||||
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
|
||||
r := setupRouter(server)
|
||||
|
||||
var routes []string
|
||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
route = strings.ReplaceAll(route, "/*/", "/")
|
||||
handlerName := runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()
|
||||
// Clean up the handler name (remove package path)
|
||||
// For example, "github.com/gesellix/bose-soundtouch/cmd/soundtouch-service.setupRouter.func1"
|
||||
// or "command-line-arguments.setupRouter.func1"
|
||||
// or "main.setupRouter.func1"
|
||||
parts := strings.Split(handlerName, "/")
|
||||
if len(parts) > 0 {
|
||||
handlerName = parts[len(parts)-1]
|
||||
}
|
||||
// Now we might have "soundtouch-service.setupRouter.func1"
|
||||
// or "command-line-arguments.setupRouter.func1"
|
||||
// or "main.setupRouter.func1"
|
||||
// Let's remove the first part if it's a known varying package name
|
||||
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
|
||||
handlerName = handlerName[idx:]
|
||||
}
|
||||
// In case it's not setupRouter but still has a package prefix
|
||||
for {
|
||||
dotIdx := strings.Index(handlerName, ".")
|
||||
if dotIdx == -1 {
|
||||
break
|
||||
}
|
||||
prefix := handlerName[:dotIdx]
|
||||
if prefix == "main" || prefix == "command-line-arguments" || strings.Contains(prefix, "soundtouch-service") {
|
||||
handlerName = handlerName[dotIdx+1:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove any ".funcN" suffix if it's an anonymous function
|
||||
if idx := strings.Index(handlerName, ".func"); idx != -1 {
|
||||
handlerName = handlerName[:idx]
|
||||
}
|
||||
|
||||
routes = append(routes, fmt.Sprintf("%-8s %-60s %s", method, route, handlerName))
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := chi.Walk(r, walkFunc); err != nil {
|
||||
t.Fatalf("Failed to walk routes: %v", err)
|
||||
}
|
||||
|
||||
sort.Strings(routes)
|
||||
|
||||
output := strings.Join(routes, "\n") + "\n"
|
||||
|
||||
// Define snapshot path
|
||||
snapshotPath := "testdata/router_routes.txt"
|
||||
actualPath := "testdata/router_routes.actual.txt"
|
||||
|
||||
// Always write the current (actual) routes to a file
|
||||
if err := os.WriteFile(actualPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("Failed to write actual routes: %v", err)
|
||||
}
|
||||
|
||||
// Check if snapshot exists
|
||||
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
|
||||
// Create testdata directory if it doesn't exist
|
||||
if err := os.MkdirAll("testdata", 0755); err != nil {
|
||||
t.Fatalf("Failed to create testdata directory: %v", err)
|
||||
}
|
||||
// Initial snapshot creation
|
||||
if err := os.WriteFile(snapshotPath, []byte(output), 0644); err != nil {
|
||||
t.Fatalf("Failed to write snapshot: %v", err)
|
||||
}
|
||||
t.Logf("Initial snapshot created at %s", snapshotPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing snapshot
|
||||
existingOutput, err := os.ReadFile(snapshotPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read snapshot: %v", err)
|
||||
}
|
||||
|
||||
if string(existingOutput) != output {
|
||||
t.Errorf("Router routes changed! Diff the snapshot at %s with %s", snapshotPath, actualPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPUTRenameRoutesToLocalHandler reproduces the runtime routing
|
||||
// behaviour the user saw on their deployed v0.80.0: a PUT to
|
||||
// /streaming/account/{a}/device/{d} should land on
|
||||
// HandleMargeUpdateDevice, not fall through to the [UNHANDLED]
|
||||
// proxy. The handlers-package test (TestIssue285_*) uses a simplified
|
||||
// router that doesn't have the overlapping `/device` and
|
||||
// `/device/{device}` route groups, so it can't catch a chi radix-
|
||||
// tree resolution that prefers the more-specific subrouter.
|
||||
//
|
||||
// This test exercises the actual production setupRouter so a
|
||||
// regression in the route topology is caught against the same chi
|
||||
// behaviour speakers will see.
|
||||
func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "router-rename-")
|
||||
if err != nil {
|
||||
t.Fatalf("mkdir temp: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
|
||||
r := setupRouter(server)
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
body := `<?xml version="1.0" encoding="UTF-8" ?><device deviceid="A81B6A536A98"><name>Sound Machinechen</name><macaddress>A81B6A536A98</macaddress></device>`
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
ts.URL+"/streaming/account/1111111/device/A81B6A536A98",
|
||||
strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("build request: %v", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/xml")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 200 means our local HandleMargeUpdateDevice handled it.
|
||||
// 401 / 502 / anything else means the request fell through to
|
||||
// the [UNHANDLED] proxy and got the upstream response — which
|
||||
// is exactly the failure mode #285 was supposed to fix.
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT status = %d, want 200 (local handler). Anything else means the request fell through to [UNHANDLED] proxy — chi is routing to a different subrouter than the PUT registration intended.", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
*.actual.txt
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
|
||||
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
|
||||
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
|
||||
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
|
||||
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
|
||||
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
|
||||
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
|
||||
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
|
||||
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
|
||||
GET / handlers.(*Server).HandleRoot-fm
|
||||
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
|
||||
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
|
||||
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
|
||||
GET /bmx-icons/* handlers.(*Server).HandleBmxIcons
|
||||
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
|
||||
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
|
||||
GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm
|
||||
GET /bmx/tunein/v1/navigate/* handlers.(*Server).HandleTuneInNavigate-fm
|
||||
GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(*Server).HandleTuneInPlaybackPodcast-fm
|
||||
GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm
|
||||
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
|
||||
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
|
||||
GET /ced/* handlers.(*Server).HandleCedStatic
|
||||
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
|
||||
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
|
||||
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
|
||||
GET /docs/* handlers.(*Server).HandleDocs-fm
|
||||
GET /favicon.ico setupRouter
|
||||
GET /health handlers.(*Server).HandleHealth-fm
|
||||
GET /media/* handlers.(*Server).HandleMedia
|
||||
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
|
||||
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
|
||||
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
|
||||
GET /mgmt/amazon/accounts handlers.(*Server).HandleMgmtAmazonAccounts-fm
|
||||
GET /mgmt/amazon/callback handlers.(*Server).HandleMgmtAmazonCallback-fm
|
||||
GET /mgmt/amazon/token handlers.(*Server).HandleMgmtAmazonToken-fm
|
||||
GET /mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
|
||||
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
|
||||
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
|
||||
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
|
||||
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
|
||||
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
|
||||
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
|
||||
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
|
||||
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
|
||||
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
|
||||
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
|
||||
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
|
||||
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
|
||||
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
|
||||
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
|
||||
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
|
||||
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
|
||||
GET /setup/parity-mismatches handlers.(*Server).HandleListParityMismatches-fm
|
||||
GET /setup/proxy-settings handlers.(*Server).HandleGetProxySettings-fm
|
||||
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
|
||||
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
|
||||
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
|
||||
GET /streaming/account/{account}/device/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
|
||||
GET /streaming/account/{account}/device/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
|
||||
GET /streaming/account/{account}/device/{device}/presets handlers.(*Server).HandleMargePresets-fm
|
||||
GET /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /streaming/account/{account}/device/{device}/recents handlers.(*Server).HandleMargeRecents-fm
|
||||
GET /streaming/account/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
|
||||
GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm
|
||||
GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
|
||||
GET /streaming/account/{account}/presets handlers.(*Server).HandleMargeAccountPresets-fm
|
||||
GET /streaming/account/{account}/presets/all handlers.(*Server).HandleMargeAccountPresets-fm
|
||||
GET /streaming/account/{account}/provider_settings handlers.(*Server).HandleMargeProviderSettings-fm
|
||||
GET /streaming/account/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
|
||||
GET /streaming/device/{device}/streaming_token handlers.(*Server).HandleMargeStreamingToken-fm
|
||||
GET /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeGetDeviceSettings-fm
|
||||
GET /streaming/resources/api_versions.xml handlers.(*Server).HandleMargeAPIVersions-fm
|
||||
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm
|
||||
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /v1/blacklist/{deviceId} setupRouter
|
||||
GET /web/* setupRouter.(*Server).HandleWeb
|
||||
HEAD /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
OPTIONS /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PATCH /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
|
||||
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
|
||||
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
|
||||
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
|
||||
POST /core02/svc-bmx-adapter-orion/prod/orion/token handlers.(*Server).HandleOrionToken-fm
|
||||
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
|
||||
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
|
||||
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
|
||||
POST /mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
|
||||
POST /mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm
|
||||
POST /mgmt/amazon/init handlers.(*Server).HandleMgmtAmazonInit-fm
|
||||
POST /mgmt/amazon/prime handlers.(*Server).HandleMgmtPrimeDeviceAmazon-fm
|
||||
POST /mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
|
||||
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
|
||||
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
|
||||
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
|
||||
POST /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs handlers.(*Server).HandleBoseAccountToken-fm
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token handlers.(*Server).HandleBoseLegacyToken-fm
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs1 handlers.(*Server).HandleBoseToken-fm
|
||||
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3 handlers.(*Server).HandleBoseToken-fm
|
||||
POST /setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
|
||||
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
|
||||
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
|
||||
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
|
||||
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
|
||||
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
|
||||
POST /setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
|
||||
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
|
||||
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
|
||||
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
|
||||
POST /setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
|
||||
POST /setup/settings handlers.(*Server).HandleUpdateSettings-fm
|
||||
POST /setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
|
||||
POST /setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
|
||||
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
|
||||
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
|
||||
POST /setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
|
||||
POST /streaming/account handlers.(*Server).HandleMargeCreateAccount-fm
|
||||
POST /streaming/account/login handlers.(*Server).HandleMargeLogin-fm
|
||||
POST /streaming/account/{account}/device/ handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeAddDevice-fm
|
||||
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
|
||||
POST /streaming/account/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /streaming/account/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
|
||||
POST /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
|
||||
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
|
||||
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
|
||||
POST /streaming/music/musicprovider/{providerID}/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
|
||||
POST /streaming/music/musicprovider/{providerID}/trial/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
|
||||
POST /streaming/stats/error handlers.(*Server).HandleErrorStats-fm
|
||||
POST /streaming/stats/usage handlers.(*Server).HandleUsageStats-fm
|
||||
POST /streaming/support/customersupport handlers.(*Server).HandleMargeCustomerSupport-fm
|
||||
POST /streaming/support/power_on handlers.(*Server).HandleMargePowerOn-fm
|
||||
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
|
||||
PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm
|
||||
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
|
||||
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
|
||||
@@ -0,0 +1,2 @@
|
||||
soundtouch-web
|
||||
soundtouch-web-test
|
||||
@@ -0,0 +1,276 @@
|
||||
# SoundTouch Web Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
The `soundtouch-web` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Single-Page Application Design
|
||||
|
||||
The architecture eliminates Go template dependencies and provides:
|
||||
- **JSON API Backend**: Pure Go server returning only JSON responses
|
||||
- **Client-Side Rendering**: JavaScript handles all HTML generation
|
||||
- **WebSocket Real-time**: Bi-directional communication for live updates
|
||||
- **Better Performance**: No server-side template processing
|
||||
- **Easier Development**: Clear separation of frontend/backend concerns
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. Main Application (`main.go`)
|
||||
- **Entry Point**: Handles command-line arguments and application initialization
|
||||
- **SPA Routing**: Serves static HTML file for all non-API routes
|
||||
- **Device Discovery**: Automatic discovery of SoundTouch devices using unified discovery service
|
||||
- **JSON API Server**: Configures API routes and serves the SPA
|
||||
- **Context Management**: Proper context handling for timeouts and cancellation
|
||||
|
||||
#### 2. HTTP Handlers (`handlers/handlers.go`)
|
||||
- **WebApp Structure**: Central application state management
|
||||
- **JSON API Endpoints**: RESTful API returning only JSON responses
|
||||
- **Device Control**: Device control with proper validation and error handling
|
||||
- **Modular Design**: Separated control actions into focused functions
|
||||
|
||||
#### 3. WebSocket Support (`handlers/websocket.go`)
|
||||
- **Real-time Updates**: Live device status streaming to web clients
|
||||
- **Device WebSocket Connections**: Maintains persistent connections to SoundTouch devices
|
||||
- **Event Handling**: Processes nowPlaying, volume, and connection state updates
|
||||
- **Status Synchronization**: Keeps device status current across all connected clients
|
||||
|
||||
#### 4. Type Definitions (`webtypes/types.go`)
|
||||
- **Device Management**: Structures for device connections and status
|
||||
- **API Responses**: Standardized JSON response format
|
||||
- **WebSocket Messages**: Real-time message types
|
||||
- **Template Data**: HTML template data structures
|
||||
|
||||
### Key Features Implemented
|
||||
|
||||
#### Device Discovery & Management
|
||||
- **Auto-discovery**: Finds SoundTouch devices on local network using mDNS/UPnP
|
||||
- **Multi-device Support**: Manages multiple devices simultaneously
|
||||
- **Connection Tracking**: Monitors device availability and connection status
|
||||
- **Device Information**: Displays device details (name, type, IP address)
|
||||
|
||||
#### Real-time Control Interface
|
||||
- **Now Playing**: Live track information with artwork display
|
||||
- **Playback Controls**: Play/pause/stop/next/previous with visual feedback
|
||||
- **Volume Control**: Real-time volume slider with mute functionality
|
||||
- **Bass Adjustment**: Bass level control for supported devices
|
||||
- **Preset Management**: Quick access to saved presets (1-6)
|
||||
- **Source Selection**: Input switching (Spotify, TuneIn, Bluetooth, AUX, etc.)
|
||||
|
||||
#### Web Interface
|
||||
- **Single-Page Application**: Self-contained HTML file with embedded CSS and JavaScript
|
||||
- **Responsive Design**: Bootstrap 5-based UI optimized for desktop and mobile
|
||||
- **Client-Side Routing**: JavaScript handles page navigation without page reloads
|
||||
- **Dynamic Rendering**: All HTML generated client-side from JSON data
|
||||
- **Real-time Updates**: WebSocket-powered live status updates
|
||||
- **Performance Optimized**: Fast loading and no template rendering delays
|
||||
|
||||
#### API Endpoints
|
||||
```
|
||||
GET / # SPA - serves static/index.html
|
||||
GET /api/devices # List all devices (JSON)
|
||||
GET /api/device/{id} # Get device info (JSON)
|
||||
POST /api/discover # Trigger device discovery
|
||||
GET /api/control/{id}/play # Playback control
|
||||
GET /api/control/{id}/pause # Pause playback
|
||||
GET /api/control/{id}/stop # Stop playback
|
||||
GET /api/control/{id}/next # Next track
|
||||
GET /api/control/{id}/previous # Previous track
|
||||
POST /api/control/{id}/volume # Set volume (JSON body)
|
||||
GET /api/control/{id}/mute # Toggle mute
|
||||
POST /api/control/{id}/bass # Set bass level (JSON body)
|
||||
GET /api/control/{id}/preset?id=N # Select preset
|
||||
GET /api/control/{id}/source?name=X # Select source
|
||||
```
|
||||
|
||||
#### WebSocket Events
|
||||
- **Connection**: `ws://localhost:8080/ws`
|
||||
- **Device Updates**: Real-time device list changes
|
||||
- **Status Updates**: Live playback and volume changes
|
||||
- **Connection Monitoring**: Device availability status
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Frontend Architecture
|
||||
- **Single HTML File**: Complete application in `static/index.html`
|
||||
- **Embedded CSS**: Bootstrap 5 with custom Bose-inspired styling
|
||||
- **Vanilla JavaScript**: No framework dependencies, fast performance
|
||||
- **Client-Side Routing**: JavaScript manages page state without reloads
|
||||
- **Dynamic Components**: HTML elements generated from JSON API responses
|
||||
|
||||
### Error Handling & Validation
|
||||
- **Input Validation**: Proper bounds checking for volume (0-100) and bass (-9 to 9)
|
||||
- **HTTP Status Codes**: Appropriate response codes for different error conditions
|
||||
- **JSON Error Responses**: Structured error messages for API consumers
|
||||
- **Client-Side Error Display**: JavaScript toast notifications for user feedback
|
||||
|
||||
### Code Quality
|
||||
- **golangci-lint Compliance**: Passes all configured lint checks
|
||||
- **Context Handling**: Proper context propagation and timeout management
|
||||
- **Error Checking**: All JSON encoding/decoding operations checked
|
||||
- **Type Safety**: Strong typing with dedicated type package
|
||||
- **Test Coverage**: Comprehensive unit tests for handlers and types
|
||||
|
||||
### WebSocket Integration
|
||||
- **Gabbo Protocol**: Native SoundTouch WebSocket protocol implementation
|
||||
- **Event Processing**: Handles all documented SoundTouch WebSocket events
|
||||
- **Connection Management**: Automatic reconnection and health monitoring
|
||||
- **Bi-directional Communication**: Both status monitoring and device control
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Libraries
|
||||
- **chi v5**: HTTP router (inherited from existing codebase)
|
||||
- **gorilla/websocket**: WebSocket implementation
|
||||
- **Go standard library**: html/template, net/http, encoding/json
|
||||
|
||||
### Project Dependencies
|
||||
- **pkg/client**: SoundTouch HTTP and WebSocket client library
|
||||
- **pkg/discovery**: Device discovery service (mDNS/UPnP)
|
||||
- **pkg/models**: XML/JSON data structures for SoundTouch API
|
||||
- **pkg/config**: Configuration management
|
||||
|
||||
### Frontend Dependencies
|
||||
- **Bootstrap 5**: CSS framework for responsive design
|
||||
- **Bootstrap Icons**: Icon library for UI elements
|
||||
- **Vanilla JavaScript**: No external JS frameworks, pure WebSocket implementation
|
||||
|
||||
## Build & Testing
|
||||
|
||||
### Build Commands
|
||||
```bash
|
||||
# Build the web application
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
|
||||
# Build all project components (includes soundtouch-web)
|
||||
make build
|
||||
|
||||
# Cross-platform builds
|
||||
make build-all
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run unit tests
|
||||
go test ./cmd/soundtouch-web/...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./cmd/soundtouch-web/...
|
||||
|
||||
# Lint checking
|
||||
golangci-lint run cmd/soundtouch-web/...
|
||||
```
|
||||
|
||||
### Development Server
|
||||
```bash
|
||||
# Run development server
|
||||
cd cmd/soundtouch-web
|
||||
go run main.go -port 8080
|
||||
|
||||
# Access the web interface
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Command Line Options
|
||||
```bash
|
||||
soundtouch-web [options]
|
||||
|
||||
Options:
|
||||
-port string Web server port (default "8080")
|
||||
-host string Specific device host for single-device mode (optional)
|
||||
```
|
||||
|
||||
### File Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
├── main.go # Application entry point
|
||||
├── soundtouch-web # Built binary
|
||||
├── handlers/
|
||||
│ ├── handlers.go # HTTP request handlers
|
||||
│ ├── handlers_test.go # Handler tests
|
||||
│ └── websocket.go # WebSocket functionality
|
||||
├── webtypes/
|
||||
│ ├── types.go # Type definitions
|
||||
│ └── types_test.go # Type tests
|
||||
├── templates/
|
||||
│ ├── layout.html # Base HTML layout
|
||||
│ ├── index.html # Device list page
|
||||
│ └── device.html # Device control page
|
||||
├── static/
|
||||
│ └── style.css # Additional CSS styles
|
||||
└── README.md # User documentation
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Browsers
|
||||
- **Chrome 80+** (recommended)
|
||||
- **Firefox 75+**
|
||||
- **Safari 13+**
|
||||
- **Edge 80+**
|
||||
|
||||
### Required Features
|
||||
- WebSocket support
|
||||
- CSS Grid and Flexbox
|
||||
- ES6 JavaScript features
|
||||
- JSON API support
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Design Principles
|
||||
- **Local Network Only**: Designed for trusted local network environments
|
||||
- **No Authentication**: Assumes local network security
|
||||
- **CORS Policy**: Restricted to same-origin requests
|
||||
- **Input Validation**: All user inputs validated on server side
|
||||
|
||||
### Network Security
|
||||
- **Port Usage**: Uses standard HTTP port (configurable)
|
||||
- **WebSocket Security**: Same-origin WebSocket connections only
|
||||
- **No External Dependencies**: All resources served locally
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Resource Usage
|
||||
- **Memory**: Minimal footprint, scales with number of discovered devices
|
||||
- **CPU**: Low usage, event-driven architecture
|
||||
- **Network**: Efficient WebSocket connections, HTTP REST for control
|
||||
|
||||
### Scalability
|
||||
- **Device Limits**: Designed for typical home networks (5-20 devices)
|
||||
- **Concurrent Users**: Multiple browser sessions supported
|
||||
- **Update Frequency**: Real-time updates without polling
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Features
|
||||
- **Zone Management**: Multi-room audio control
|
||||
- **Preset Programming**: Advanced preset configuration
|
||||
- **Mobile PWA**: Progressive Web App for mobile installation
|
||||
- **Theme Support**: Additional UI themes
|
||||
- **Device Grouping**: Logical device organization
|
||||
|
||||
### Technical Improvements
|
||||
- **Caching**: Enhanced device status caching
|
||||
- **Compression**: WebSocket message compression
|
||||
- **Persistence**: Device settings persistence
|
||||
- **Metrics**: Usage analytics and performance monitoring
|
||||
|
||||
## Integration with Main Project
|
||||
|
||||
### Project Alignment
|
||||
- **Consistent Architecture**: Follows established project patterns
|
||||
- **Shared Libraries**: Leverages existing pkg/ modules
|
||||
- **Build Integration**: Included in main Makefile targets
|
||||
- **Documentation**: Consistent with project documentation standards
|
||||
|
||||
### Migration Path
|
||||
- **Cloud Replacement**: Serves as local alternative to Bose cloud services
|
||||
- **API Compatibility**: Maintains compatibility with existing SoundTouch APIs
|
||||
- **User Experience**: Familiar interface for existing SoundTouch app users
|
||||
- **Long-term Support**: Designed for continued operation post-2026
|
||||
|
||||
This implementation provides a robust, feature-complete web interface for SoundTouch device control, ensuring continued functionality beyond the official app's lifecycle while maintaining high code quality and user experience standards.
|
||||
@@ -0,0 +1,330 @@
|
||||
# SoundTouch Web UI
|
||||
|
||||
A modern single-page web application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering for superior performance and maintainability.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser → Static HTML → JavaScript → JSON API → Go Server
|
||||
↓
|
||||
Client-Side Rendering
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
- **Better Performance**: No server-side template processing overhead
|
||||
- **Improved Maintainability**: Clear separation between frontend (JavaScript) and backend (Go)
|
||||
- **Real-time Experience**: Smooth client-side updates without page reloads
|
||||
- **Mobile Ready**: The JSON API can power both this web interface and mobile applications
|
||||
|
||||
## Features
|
||||
|
||||
Based on captured WebSocket interactions and device API capabilities, this web UI provides:
|
||||
|
||||
### Device Management
|
||||
- **Auto-discovery** of SoundTouch devices on the network
|
||||
- **Real-time status monitoring** via WebSocket connections
|
||||
- **Multi-device support** with centralized control
|
||||
- **Connection status** indicators and health monitoring
|
||||
|
||||
### Playback Control
|
||||
- **Play/Pause/Stop/Next/Previous** controls
|
||||
- **Now playing information** with artwork, track details, and progress
|
||||
- **Real-time updates** of playback state changes
|
||||
- **Source selection** from available inputs (Spotify, TuneIn, Bluetooth, AUX, etc.)
|
||||
|
||||
### Audio Controls
|
||||
- **Volume control** with real-time slider updates
|
||||
- **Mute/Unmute** functionality
|
||||
- **Bass adjustment** (on supported models)
|
||||
- **Audio level monitoring** and statistics
|
||||
|
||||
### Preset Management
|
||||
- **6 preset buttons** with visual feedback
|
||||
- **Preset content display** showing station/playlist names
|
||||
- **One-click preset selection**
|
||||
|
||||
### Advanced Features
|
||||
- **WebSocket real-time updates** for instant state synchronization
|
||||
- **Responsive design** optimized for desktop and mobile
|
||||
- **Dark mode support** (auto-detects system preference)
|
||||
- **Accessibility features** (keyboard navigation, screen reader support)
|
||||
- **Network statistics** and device health monitoring
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Main Device Overview
|
||||
The main page shows all discovered devices with their current status, now-playing information, and quick controls.
|
||||
|
||||
### Detailed Device Control
|
||||
Individual device pages provide full control over:
|
||||
- Detailed now-playing information with artwork
|
||||
- Comprehensive audio controls (volume, bass)
|
||||
- Full preset and source selection
|
||||
- Real-time status updates
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.21 or later
|
||||
- Access to SoundTouch devices on the same network
|
||||
- Modern web browser with WebSocket support
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# From project root
|
||||
make build
|
||||
|
||||
# Or manually
|
||||
cd cmd/soundtouch-web
|
||||
go build -o soundtouch-web
|
||||
```
|
||||
|
||||
### Running
|
||||
```bash
|
||||
# Run with default settings (port 8080)
|
||||
./soundtouch-web
|
||||
|
||||
# Specify custom port
|
||||
./soundtouch-web -port 8888
|
||||
|
||||
# Connect to specific device
|
||||
./soundtouch-web -host 192.168.1.100
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
```
|
||||
-port string Web server port (default "8080")
|
||||
-host string Specific SoundTouch device host (optional, enables single-device mode)
|
||||
-help Show help information
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Accessing the Interface
|
||||
1. Start the application
|
||||
2. Open your web browser and navigate to `http://localhost:8080`
|
||||
3. Click "Discover Devices" to find SoundTouch devices on your network
|
||||
4. Click on any device for detailed control, or use quick controls from the main page
|
||||
|
||||
### Device Discovery
|
||||
The application automatically discovers SoundTouch devices using:
|
||||
- **mDNS discovery** for local network devices
|
||||
- **UPnP/SSDP discovery** as fallback
|
||||
- **Manual device addition** via IP address
|
||||
|
||||
### Real-time Updates
|
||||
The interface maintains WebSocket connections to each device for instant updates of:
|
||||
- Now playing information and artwork
|
||||
- Volume and audio settings changes
|
||||
- Playback status (play/pause/stop)
|
||||
- Connection status and device health
|
||||
|
||||
### Responsive Design
|
||||
- **Desktop**: Full-featured interface with side-by-side panels
|
||||
- **Tablet**: Optimized layout with touch-friendly controls
|
||||
- **Mobile**: Stacked interface with gesture support
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The web UI exposes a REST API for programmatic control:
|
||||
|
||||
### Device Management
|
||||
```
|
||||
GET /api/devices # List all discovered devices
|
||||
GET /api/device/{id} # Get specific device info
|
||||
POST /api/discover # Trigger device discovery
|
||||
```
|
||||
|
||||
### Device Control
|
||||
```
|
||||
GET /api/control/{id}/play # Start playback
|
||||
GET /api/control/{id}/pause # Pause playback
|
||||
GET /api/control/{id}/stop # Stop playback
|
||||
GET /api/control/{id}/next # Next track
|
||||
GET /api/control/{id}/previous # Previous track
|
||||
POST /api/control/{id}/volume # Set volume (body: {"level": 50})
|
||||
GET /api/control/{id}/mute # Mute audio
|
||||
GET /api/control/{id}/unmute # Unmute audio
|
||||
POST /api/control/{id}/bass # Set bass (body: {"level": 0})
|
||||
GET /api/control/{id}/preset?id=1 # Select preset
|
||||
GET /api/control/{id}/source?name=SPOTIFY # Select source
|
||||
```
|
||||
|
||||
### WebSocket Events
|
||||
Connect to `/ws` for real-time updates:
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8080/ws');
|
||||
ws.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
// Handle device updates, status changes, etc.
|
||||
};
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Single-Page Application Architecture
|
||||
- **JSON API Backend**: Go server providing RESTful endpoints
|
||||
- **Client-Side Rendering**: JavaScript handles all UI rendering
|
||||
- **WebSocket Real-time**: Bi-directional real-time communication
|
||||
- **No Template Dependencies**: Eliminates server-side template issues
|
||||
|
||||
### Backend Components
|
||||
- **Discovery Service**: Finds and manages SoundTouch devices
|
||||
- **WebSocket Manager**: Maintains real-time connections to devices
|
||||
- **JSON API Server**: RESTful interface returning only JSON
|
||||
- **Device Manager**: Tracks device state and health
|
||||
|
||||
### Frontend Components
|
||||
- **Bootstrap 5**: Modern responsive UI framework
|
||||
- **Vanilla JavaScript**: No framework dependencies, fast loading
|
||||
- **WebSocket Client**: Real-time bidirectional communication
|
||||
- **Dynamic Rendering**: Client-side HTML generation from JSON
|
||||
|
||||
### Communication Flow
|
||||
1. **SPA Loading**: Single HTML file with embedded CSS and JavaScript
|
||||
2. **JSON API**: Device discovery and control via REST endpoints
|
||||
3. **WebSocket (Device)**: Real-time status updates from SoundTouch devices
|
||||
4. **WebSocket (Browser)**: Real-time UI updates to web clients
|
||||
5. **Client Rendering**: JavaScript dynamically creates all UI elements
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
cmd/soundtouch-web/
|
||||
├── main.go # Application entry point and SPA routing
|
||||
├── handlers/ # HTTP and WebSocket handlers
|
||||
│ ├── handlers.go # JSON API endpoints
|
||||
│ └── websocket.go # WebSocket management
|
||||
├── webtypes/ # Type definitions
|
||||
│ └── types.go # Request/response types
|
||||
├── static/ # Static assets
|
||||
│ ├── index.html # Single-page application
|
||||
│ └── js/ # Legacy JS files (reference)
|
||||
├── templates/ # Legacy templates (unused in SPA)
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
1. **API Endpoints**: Add new JSON routes in `setupRoutes()` and `handlers.go`
|
||||
2. **WebSocket Events**: Extend event handlers in WebSocket client
|
||||
3. **UI Components**: Add JavaScript rendering functions in `static/index.html`
|
||||
4. **Device Controls**: Implement new control commands and update client-side handlers
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Unit tests
|
||||
go test ./...
|
||||
|
||||
# Manual testing with multiple devices
|
||||
./soundtouch-web -port 8080
|
||||
|
||||
# API testing
|
||||
curl http://localhost:8080/api/devices
|
||||
```
|
||||
|
||||
## WebSocket Protocol Analysis
|
||||
|
||||
This UI is based on extensive analysis of captured SoundTouch WebSocket interactions, including:
|
||||
|
||||
### Message Types Implemented
|
||||
- **SoundTouchSdkInfo**: Initial handshake and version info
|
||||
- **nowPlayingUpdated**: Real-time track information
|
||||
- **volumeUpdated**: Audio level changes
|
||||
- **recentsUpdated**: Recently played items
|
||||
- **userActivityUpdate**: User interaction notifications
|
||||
|
||||
### Request/Response Patterns
|
||||
- **Device Information**: System details and capabilities
|
||||
- **Audio Controls**: Volume, bass, mute controls
|
||||
- **Playback Control**: Play/pause/stop/skip commands
|
||||
- **Source Selection**: Input switching (Spotify, TuneIn, etc.)
|
||||
- **Preset Management**: Saved station/playlist access
|
||||
|
||||
### Gabbo Protocol Features
|
||||
- **Persistent Connections**: Maintains long-lived WebSocket connections
|
||||
- **Request Correlation**: Uses request IDs for response matching
|
||||
- **Real-time Events**: Instant updates for all device state changes
|
||||
- **Bi-directional Control**: Both status monitoring and device control
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
### Supported Browsers
|
||||
- **Chrome 80+** (recommended)
|
||||
- **Firefox 75+**
|
||||
- **Safari 13+**
|
||||
- **Edge 80+**
|
||||
|
||||
### Required Features
|
||||
- WebSocket support
|
||||
- CSS Grid and Flexbox
|
||||
- ES6 JavaScript features
|
||||
- Responsive CSS media queries
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Local Network Only**: Designed for local network device control
|
||||
- **No Authentication**: Assumes trusted local network environment
|
||||
- **CORS Policy**: Restricted to same-origin requests
|
||||
- **WebSocket Security**: Uses same-origin WebSocket connections
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Devices Not Found**
|
||||
- Ensure devices are on the same network
|
||||
- Check firewall settings (ports 8090, 8080)
|
||||
- Click "Discover Devices" button to trigger discovery
|
||||
|
||||
**WebSocket Connection Failed**
|
||||
- Verify device supports WebSocket connections
|
||||
- Check browser console for connection errors
|
||||
- Refresh the page to reconnect WebSocket
|
||||
|
||||
**Control Commands Not Working**
|
||||
- Check device is powered on and connected
|
||||
- Verify device is not in exclusive mode (e.g., Spotify Connect active)
|
||||
- Look for error notifications in the UI
|
||||
|
||||
**Page Shows Template Errors**
|
||||
- This has been fixed in the SPA implementation
|
||||
- Ensure you're accessing the correct URL (localhost:8080)
|
||||
- Clear browser cache if you see old template-based content
|
||||
|
||||
### Debug Mode
|
||||
Add verbose logging by setting environment variable:
|
||||
```bash
|
||||
export DEBUG=true
|
||||
./soundtouch-web
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This web UI is part of the larger SoundTouch Go library project. See the main project README for contribution guidelines.
|
||||
|
||||
### Architecture Benefits
|
||||
The new SPA approach provides:
|
||||
- **Better Performance**: No server-side template rendering
|
||||
- **Easier Development**: Clear separation of frontend/backend
|
||||
- **Mobile Ready**: Same JSON API can power mobile apps
|
||||
- **Scalable**: Single-page app architecture
|
||||
|
||||
### Feature Requests
|
||||
Based on WebSocket interaction analysis, potential future features:
|
||||
- Zone/multi-room management
|
||||
- Clock display control
|
||||
- Software update management
|
||||
- Advanced preset programming
|
||||
- Progressive Web App (PWA) features
|
||||
|
||||
## License
|
||||
|
||||
Same as the parent project - see main repository LICENSE file.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Built on the comprehensive SoundTouch Go library
|
||||
- UI design inspired by modern audio control interfaces
|
||||
- WebSocket protocol reverse-engineered from captured device interactions
|
||||
- Bootstrap and Bootstrap Icons for responsive design components
|
||||
@@ -0,0 +1,648 @@
|
||||
// Package handlers contains HTTP handlers for the SoundTouch web UI.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebApp holds the application state and dependencies
|
||||
type WebApp struct {
|
||||
Devices map[string]*webtypes.DeviceConnection
|
||||
Upgrader websocket.Upgrader
|
||||
WSClients map[*websocket.Conn]bool
|
||||
WSMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewWebApp creates a new WebApp instance for SPA mode
|
||||
func NewWebApp() *WebApp {
|
||||
return &WebApp{
|
||||
Devices: make(map[string]*webtypes.DeviceConnection),
|
||||
WSClients: make(map[*websocket.Conn]bool),
|
||||
Upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevices returns all devices as JSON
|
||||
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Return all devices as JSON
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevice returns a specific device as JSON
|
||||
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update device status to get fresh power state
|
||||
app.UpdateDeviceStatus(deviceID, device)
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIControl handles device control commands
|
||||
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
action := chi.URLParam(r, "action")
|
||||
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
app.handleControlAction(w, r, action, device)
|
||||
}
|
||||
|
||||
// handleControlAction processes different control actions
|
||||
func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, action string, device *webtypes.DeviceConnection) {
|
||||
switch action {
|
||||
case "play":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Play()
|
||||
app.sendControlResponse(w, err, "Started playback")
|
||||
case "pause":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Pause()
|
||||
app.sendControlResponse(w, err, "Paused playback")
|
||||
case "stop":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Stop()
|
||||
app.sendControlResponse(w, err, "Stopped playback")
|
||||
case "next":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.NextTrack()
|
||||
app.sendControlResponse(w, err, "Next track")
|
||||
case "previous":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.PrevTrack()
|
||||
app.sendControlResponse(w, err, "Previous track")
|
||||
case "volume":
|
||||
app.handleVolumeControl(w, r, device)
|
||||
case "mute":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SendKey(models.KeyMute)
|
||||
app.sendControlResponse(w, err, "Toggled mute")
|
||||
case "preset":
|
||||
app.handlePresetControl(w, r, device)
|
||||
case "bass":
|
||||
app.handleBassControl(w, r, device)
|
||||
case "source":
|
||||
app.handleSourceControl(w, r, device)
|
||||
default:
|
||||
app.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVolumeControl processes volume control requests
|
||||
func (app *WebApp) handleVolumeControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for volume control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var volumeReq webtypes.VolumeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&volumeReq); err != nil {
|
||||
app.sendError(w, "Invalid volume data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if volumeReq.Level < 0 || volumeReq.Level > 100 {
|
||||
app.sendError(w, "Volume must be between 0 and 100", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetVolume(volumeReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeReq.Level))
|
||||
}
|
||||
|
||||
// handlePresetControl processes preset control requests
|
||||
func (app *WebApp) handlePresetControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
presetParam := r.URL.Query().Get("id")
|
||||
if presetParam == "" {
|
||||
app.sendError(w, "Preset ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
presetID, err := strconv.Atoi(presetParam)
|
||||
if err != nil {
|
||||
app.sendError(w, "Invalid preset ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = device.Client.SelectPreset(presetID)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected preset %d", presetID))
|
||||
}
|
||||
|
||||
// handleBassControl processes bass control requests
|
||||
func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for bass control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var bassReq webtypes.BassRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&bassReq); err != nil {
|
||||
app.sendError(w, "Invalid bass data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if bassReq.Level < -9 || bassReq.Level > 9 {
|
||||
app.sendError(w, "Bass must be between -9 and 9", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetBass(bassReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level))
|
||||
}
|
||||
|
||||
// handleSourceControl processes source control requests
|
||||
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
sourceParam := r.URL.Query().Get("name")
|
||||
if sourceParam == "" {
|
||||
app.sendError(w, "Source name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SelectSource(sourceParam, "")
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected source %s", sourceParam))
|
||||
}
|
||||
|
||||
// sendControlResponse sends a control command response
|
||||
func (app *WebApp) sendControlResponse(w http.ResponseWriter, err error, successMessage string) {
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": successMessage},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// sendError sends an error response
|
||||
func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey handles sending key commands to devices
|
||||
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
key := chi.URLParam(r, "key")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err := device.Client.SendKey(key)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Sent key command: %s", key))
|
||||
}
|
||||
|
||||
// HandleDirectVolumeControl handles direct volume setting via URL parameter
|
||||
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
volumeLevel, err := strconv.Atoi(chi.URLParam(r, "volume"))
|
||||
if err != nil || volumeLevel < 0 || volumeLevel > 100 {
|
||||
app.sendError(w, "Invalid volume level (0-100)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err = device.Client.SetVolume(volumeLevel)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeLevel))
|
||||
}
|
||||
|
||||
// HandleDevicePower handles power toggle commands for devices
|
||||
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Send POWER key command to toggle device power
|
||||
err := device.Client.SendKey("POWER")
|
||||
app.sendControlResponse(w, err, "Power toggle command sent")
|
||||
}
|
||||
|
||||
// HandleDevicePowerStatus handles lightweight power status check
|
||||
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Quick power status check by getting now playing
|
||||
nowPlaying, err := device.Client.GetNowPlaying()
|
||||
if err != nil {
|
||||
app.sendControlResponse(w, err, "Failed to get power status")
|
||||
return
|
||||
}
|
||||
|
||||
isPoweredOn := nowPlaying != nil && nowPlaying.Source != "STANDBY"
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"isPoweredOn": isPoweredOn,
|
||||
"source": nowPlaying.Source,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceList sends updated device list to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDeviceList() {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send device update to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDiscoveryStatus sends discovery progress updates to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "discovery_status",
|
||||
Data: map[string]interface{}{
|
||||
"status": status,
|
||||
"deviceCount": deviceCount,
|
||||
},
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send discovery status to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package.
|
||||
func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmxpkg.TuneInSearch(query)
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
|
||||
// Supported path suffixes (relative to /api/tunein/navigate):
|
||||
// - (empty) → top-level browse
|
||||
// - /{encodedURI} → browse the given TuneIn URI
|
||||
// - /sub/{n}/{encodedURI} → single subsection
|
||||
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
|
||||
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
var (
|
||||
resp interface{}
|
||||
err error
|
||||
)
|
||||
|
||||
if wildcard == "" {
|
||||
resp, err = bmxpkg.TuneInNavigate("", nil)
|
||||
} else {
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
pfx := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch pfx {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest, nil)
|
||||
} else {
|
||||
n, parseErr := strconv.Atoi(rest[:secondSlash])
|
||||
if parseErr != nil {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
}
|
||||
}
|
||||
case "profiles":
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
|
||||
}
|
||||
default:
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
|
||||
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Location string `json:"location"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ContainerArt string `json:"containerArt"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
app.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Location == "" {
|
||||
app.sendError(w, "location is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
itemType := req.Type
|
||||
if itemType == "" {
|
||||
itemType = "stationurl"
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: itemType,
|
||||
Location: req.Location,
|
||||
ItemName: req.Name,
|
||||
IsPresetable: true,
|
||||
ContainerArt: req.ContainerArt,
|
||||
}
|
||||
|
||||
if err := device.Client.SelectContentItem(contentItem); err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]string{"message": "Playing " + req.Name}}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
// Package handlers contains tests for HTTP handlers.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func createTestApp() *WebApp {
|
||||
app := NewWebApp()
|
||||
|
||||
// Add test device with minimal data
|
||||
deviceInfo := &models.DeviceInfo{
|
||||
Name: "Test Speaker",
|
||||
Type: "SoundTouch 30",
|
||||
NetworkInfo: []models.NetworkInfo{
|
||||
{MacAddress: "TEST123", IPAddress: "192.168.1.100"},
|
||||
},
|
||||
}
|
||||
|
||||
device := &webtypes.DeviceConnection{
|
||||
Client: nil, // No real client for unit tests
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{
|
||||
Volume: &models.Volume{ActualVolume: 50, MuteEnabled: false},
|
||||
Bass: &models.Bass{ActualBass: 0},
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
app.Devices["test-device"] = device
|
||||
return app
|
||||
}
|
||||
|
||||
func withChiParams(r *http.Request, params map[string]string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
for k, v := range params {
|
||||
rctx.URLParams.Add(k, v)
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestNewWebApp(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
|
||||
// Use require-style checks that satisfy static analyzer
|
||||
if app == nil {
|
||||
t.Fatal("NewWebApp returned nil")
|
||||
}
|
||||
if app.Devices == nil {
|
||||
t.Fatal("Devices map not initialized")
|
||||
}
|
||||
|
||||
// At this point we know app and app.Devices are not nil
|
||||
if len(app.Devices) != 0 {
|
||||
t.Errorf("Expected empty devices map, got %d devices", len(app.Devices))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIDevices(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDevices(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got false")
|
||||
}
|
||||
|
||||
// Check that devices data is present
|
||||
data, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected data to be map[string]interface{}")
|
||||
}
|
||||
|
||||
if _, exists := data["test-device"]; !exists {
|
||||
t.Errorf("Expected 'test-device' in response data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIDevice(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
chiID string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "valid device",
|
||||
path: "/api/device/test-device",
|
||||
chiID: "test-device",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "missing device ID",
|
||||
path: "/api/device/",
|
||||
chiID: "",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "unknown device",
|
||||
path: "/api/device/unknown",
|
||||
chiID: "unknown",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
if tt.chiID != "" {
|
||||
req = withChiParams(req, map[string]string{"id": tt.chiID})
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDevice(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_InvalidDevice(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/control/unknown-device/play", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "unknown-device", "action": "play"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Device not found" {
|
||||
t.Errorf("Expected 'Device not found' error, got '%s'", response.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_InvalidPath(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{"missing action", "/api/control/test-device"},
|
||||
{"missing device and action", "/api/control/"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_VolumeValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "invalid method",
|
||||
method: "GET",
|
||||
body: "",
|
||||
expectedStatus: http.StatusMethodNotAllowed,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
method: "POST",
|
||||
body: `invalid json`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "volume too low",
|
||||
method: "POST",
|
||||
body: `{"level": -1}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "volume too high",
|
||||
method: "POST",
|
||||
body: `{"level": 101}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req *http.Request
|
||||
if tt.body != "" {
|
||||
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", strings.NewReader(tt.body))
|
||||
} else {
|
||||
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", nil)
|
||||
}
|
||||
req = withChiParams(req, map[string]string{"id": "test-device", "action": "volume"})
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_BassValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "bass too low",
|
||||
method: "POST",
|
||||
body: `{"level": -10}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "bass too high",
|
||||
method: "POST",
|
||||
body: `{"level": 10}`,
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, "/api/control/test-device/bass", strings.NewReader(tt.body))
|
||||
req = withChiParams(req, map[string]string{"id": "test-device", "action": "bass"})
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_PresetValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "missing preset ID",
|
||||
query: "",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "invalid preset ID",
|
||||
query: "?id=abc",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/control/test-device/preset"+tt.query, nil)
|
||||
req = withChiParams(req, map[string]string{"id": "test-device", "action": "preset"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_SourceValidation(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/control/test-device/source", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "test-device", "action": "source"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Source name required" {
|
||||
t.Errorf("Expected 'Source name required' error, got '%s'", response.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAPIDiscover(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
expectedStatus int
|
||||
expectSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "valid POST request",
|
||||
method: "POST",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "invalid GET request",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusMethodNotAllowed,
|
||||
expectSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, "/api/discover", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDiscover(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success != tt.expectSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectSuccess, response.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendError(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.sendError(w, "Test error", http.StatusBadRequest)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Test error" {
|
||||
t.Errorf("Expected 'Test error', got '%s'", response.Error)
|
||||
}
|
||||
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebSocket_InvalidUpgrade(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
// Test without proper WebSocket headers (should fail gracefully)
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// This will fail because it's not a real WebSocket upgrade, but should not panic
|
||||
app.HandleWebSocket(w, req)
|
||||
|
||||
// We're just checking that the handler doesn't panic
|
||||
// The actual upgrade will fail in test environment without proper headers
|
||||
}
|
||||
|
||||
func TestHandleAPIControl_UnsupportedAction(t *testing.T) {
|
||||
app := createTestApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/control/test-device/unsupported", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "test-device", "action": "unsupported"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false, got true")
|
||||
}
|
||||
|
||||
if response.Error != "Unknown action" {
|
||||
t.Errorf("Expected 'Unknown action' error, got '%s'", response.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkHandleAPIDevices(b *testing.B) {
|
||||
app := createTestApp()
|
||||
|
||||
// Add more devices for realistic benchmarking
|
||||
for i := 0; i < 10; i++ {
|
||||
deviceID := "device-" + string(rune('0'+i))
|
||||
app.Devices[deviceID] = &webtypes.DeviceConnection{
|
||||
Client: &client.Client{},
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device " + deviceID},
|
||||
Status: webtypes.DeviceStatus{IsConnected: true},
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIDevices(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHandleAPIDevice(b *testing.B) {
|
||||
app := createTestApp()
|
||||
req := httptest.NewRequest("GET", "/api/device/test-device", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "test-device"})
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSendError(b *testing.B) {
|
||||
app := createTestApp()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
w := httptest.NewRecorder()
|
||||
app.sendError(w, "Test error", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
// Package handlers contains WebSocket handlers for real-time communication.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// HandleWebSocket handles WebSocket connections for real-time updates
|
||||
func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := app.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Unregister client
|
||||
app.WSMutex.Lock()
|
||||
delete(app.WSClients, conn)
|
||||
app.WSMutex.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Register client
|
||||
app.WSMutex.Lock()
|
||||
app.WSClients[conn] = true
|
||||
app.WSMutex.Unlock()
|
||||
|
||||
// Send initial device list
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
log.Printf("Failed to send initial data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep connection alive and send updates
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
log.Printf("WebSocket read error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Main loop for sending periodic updates
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send periodic status updates
|
||||
for id, device := range app.Devices {
|
||||
if device.Status.IsConnected {
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: id,
|
||||
Data: device.Status,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send status update: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDiscover triggers device discovery
|
||||
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Discovery will be triggered by the main app
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "Discovery started"},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a device
|
||||
func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.DeviceConnection) {
|
||||
// Skip WebSocket connection if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wsClient := conn.Client.NewWebSocketClient(nil)
|
||||
|
||||
// Setup event handlers
|
||||
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
||||
conn.Status.NowPlaying = &event.NowPlaying
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
|
||||
conn.Status.Volume = &event.Volume
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
|
||||
conn.Status.IsConnected = event.ConnectionState.IsConnected()
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
|
||||
conn.Status.Presets = &event.Presets
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
// Connect WebSocket
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
log.Printf("Failed to connect WebSocket for device %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
conn.WebSocket = wsClient
|
||||
conn.Status.IsConnected = true
|
||||
|
||||
log.Printf("WebSocket connected for device %s", deviceID)
|
||||
|
||||
// Wait for disconnection
|
||||
wsClient.Wait()
|
||||
|
||||
conn.Status.IsConnected = false
|
||||
|
||||
log.Printf("WebSocket disconnected for device %s", deviceID)
|
||||
}
|
||||
|
||||
// UpdateDeviceStatus fetches current status from device
|
||||
func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) {
|
||||
// Skip status update if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
statusUpdated := false
|
||||
|
||||
// Get current now playing
|
||||
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
|
||||
conn.Status.NowPlaying = nowPlaying
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get current volume
|
||||
if volume, err := conn.Client.GetVolume(); err == nil {
|
||||
conn.Status.Volume = volume
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get presets
|
||||
if presets, err := conn.Client.GetPresets(); err == nil {
|
||||
conn.Status.Presets = presets
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Update last activity if any status was updated
|
||||
if statusUpdated {
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
// Get sources
|
||||
if sources, err := conn.Client.GetSources(); err == nil {
|
||||
conn.Status.Sources = sources
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get bass (if available)
|
||||
if bass, err := conn.Client.GetBass(); err == nil {
|
||||
conn.Status.Bass = bass
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Mark as connected if we successfully got at least one status
|
||||
conn.Status.IsConnected = statusUpdated
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
|
||||
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
|
||||
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
http.Error(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
http.Error(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := app.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("Device WebSocket upgrade failed for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
log.Printf("Device WebSocket connected for %s", deviceID)
|
||||
|
||||
// Send initial device status
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
log.Printf("Failed to send initial device status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
for {
|
||||
if _, _, err := conn.NextReader(); err != nil {
|
||||
log.Printf("Device WebSocket read error for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Send periodic device status updates
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping to device WebSocket %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send device status update
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send device status update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// If device has active WebSocket connection to SoundTouch device,
|
||||
// also send any real-time updates from that connection
|
||||
if device.WebSocket != nil && device.Status.IsConnected {
|
||||
realtimeMessage := webtypes.WebSocketMessage{
|
||||
Type: "device_realtime",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"nowPlaying": device.Status.NowPlaying,
|
||||
"volume": device.Status.Volume,
|
||||
"timestamp": time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(realtimeMessage); err != nil {
|
||||
log.Printf("Failed to send realtime update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// Package main provides a web UI for controlling Bose SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-web",
|
||||
Usage: "Web UI for controlling Bose SoundTouch devices",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "port",
|
||||
Aliases: []string{"p"},
|
||||
Usage: "HTTP port to listen on",
|
||||
Value: "8080",
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bind",
|
||||
Usage: "Address for the HTTP listener: host, IP, or local interface name (e.g. eth0). Leave empty to listen on all interfaces",
|
||||
EnvVars: []string{"BIND_ADDR"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "interface",
|
||||
Usage: "Network interface name (e.g. eth0) for mDNS and UPnP device discovery. Defaults to the --bind interface name when one was given; leave empty otherwise to auto-pick",
|
||||
EnvVars: []string{"DISCOVERY_INTERFACE"},
|
||||
},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
port := c.String("port")
|
||||
rawBind := c.String("bind")
|
||||
|
||||
bindAddr, err := resolveBindAddr(rawBind)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if rawBind != "" && bindAddr != rawBind {
|
||||
log.Printf("Resolved --bind %q to %s", rawBind, bindAddr)
|
||||
}
|
||||
|
||||
rawIface := c.String("interface")
|
||||
|
||||
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
|
||||
if rawIface == "" && ifaceName != "" {
|
||||
log.Printf("Defaulting --interface to %q from --bind", ifaceName)
|
||||
}
|
||||
|
||||
addr := ":" + port
|
||||
if bindAddr != "" {
|
||||
addr = bindAddr + ":" + port
|
||||
}
|
||||
|
||||
// Create web app without templates (SPA mode)
|
||||
webApp := handlers.NewWebApp()
|
||||
|
||||
// Initialize discovery service
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
log.Printf("Failed to load config: %v, using defaults", err)
|
||||
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
cfg.DiscoveryTimeout = 10 * time.Second
|
||||
cfg.CacheEnabled = true
|
||||
|
||||
if ifaceName != "" {
|
||||
cfg.DiscoveryInterface = ifaceName
|
||||
}
|
||||
|
||||
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
// Discover devices on startup
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
|
||||
|
||||
discoverDevices(ctx, webApp, discoveryService)
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
r := setupRoutes(webApp, discoveryService)
|
||||
|
||||
log.Printf("SoundTouch Web UI starting on http://%s", addr)
|
||||
|
||||
return http.ListenAndServe(addr, r)
|
||||
},
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultDiscoveryInterface picks the interface name to use for mDNS/UPnP
|
||||
// discovery. An explicit --interface always wins; otherwise, when --bind was
|
||||
// given an interface name (i.e. resolveBindAddr substituted an IP for it),
|
||||
// that name is reused so the common single-interface case "just works".
|
||||
// Returns the empty string when there is nothing to propagate, leaving the
|
||||
// discovery service to auto-pick.
|
||||
func defaultDiscoveryInterface(rawInterface, rawBind, resolvedBind string) string {
|
||||
if rawInterface != "" {
|
||||
return rawInterface
|
||||
}
|
||||
|
||||
if rawBind != "" && rawBind != resolvedBind {
|
||||
return rawBind
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveBindAddr returns the address to bind the HTTP listener to.
|
||||
//
|
||||
// If bindAddr names a local network interface, the interface's single IPv4
|
||||
// address is returned. When no IPv4 is present, the function falls back to the
|
||||
// interface's single non-link-local IPv6 address (wrapped in brackets so it
|
||||
// composes correctly with ":port"). Ambiguous interfaces (multiple addresses
|
||||
// in the chosen family) or interfaces with no usable address produce an error,
|
||||
// so misconfiguration surfaces immediately instead of becoming an obscure DNS
|
||||
// lookup failure at listen time.
|
||||
//
|
||||
// If bindAddr is not an interface name — including the empty string, a host
|
||||
// name, or a literal IP — it is returned unchanged.
|
||||
func resolveBindAddr(bindAddr string) (string, error) {
|
||||
// A lookup failure here just means bindAddr isn't an interface name
|
||||
// (it's a host, IP, or empty); fall through to pass-through.
|
||||
iface, _ := net.InterfaceByName(bindAddr)
|
||||
if iface == nil {
|
||||
return bindAddr, nil
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("--bind %q: failed to list addresses for interface: %w", bindAddr, err)
|
||||
}
|
||||
|
||||
var ipv4, ipv6 []net.IP
|
||||
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ipv4 = append(ipv4, v4)
|
||||
} else if !ip.IsLinkLocalUnicast() {
|
||||
// Skip IPv6 link-local (fe80::); it requires a zone ID and
|
||||
// can't be used as a plain "[ip]:port" listen address.
|
||||
ipv6 = append(ipv6, ip)
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(ipv4) == 1:
|
||||
return ipv4[0].String(), nil
|
||||
case len(ipv4) > 1:
|
||||
return "", fmt.Errorf("--bind %q: interface has multiple IPv4 addresses (%v); specify one directly", bindAddr, ipv4)
|
||||
case len(ipv6) == 1:
|
||||
return "[" + ipv6[0].String() + "]", nil
|
||||
case len(ipv6) > 1:
|
||||
return "", fmt.Errorf("--bind %q: interface has multiple IPv6 addresses (%v); specify one directly", bindAddr, ipv6)
|
||||
default:
|
||||
return "", fmt.Errorf("--bind %q: interface has no usable IPv4 or IPv6 address", bindAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Static assets (embedded in binary)
|
||||
subFS, _ := fs.Sub(staticFS, "static")
|
||||
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
|
||||
|
||||
// Serve index.html for SPA routes
|
||||
serveIndex := func(w http.ResponseWriter, _ *http.Request) {
|
||||
data, _ := staticFS.ReadFile("static/index.html")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// WebSocket endpoint
|
||||
r.Get("/ws", app.HandleWebSocket)
|
||||
|
||||
// API endpoints
|
||||
r.Get("/api/devices", app.HandleAPIDevices)
|
||||
r.Get("/api/device/{id}", app.HandleAPIDevice)
|
||||
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
|
||||
app.HandleAPIDiscover(w, r)
|
||||
// Trigger discovery
|
||||
//nolint:contextcheck // Context is created within goroutine
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Broadcast discovery start
|
||||
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
||||
|
||||
discoverDevices(ctx, app, discoveryService)
|
||||
|
||||
// Broadcast discovery completion and updated device list
|
||||
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
||||
app.BroadcastDeviceList()
|
||||
}()
|
||||
})
|
||||
|
||||
// Device control endpoints (GET for most actions, POST for volume/bass)
|
||||
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
|
||||
// TuneIn browse, search, and playback
|
||||
r.Get("/api/tunein/search", app.HandleTuneInSearch)
|
||||
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
|
||||
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
|
||||
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
|
||||
|
||||
// Enhanced device control endpoints
|
||||
r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey)
|
||||
r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl)
|
||||
r.Post("/api/device-power/{id}", app.HandleDevicePower)
|
||||
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
|
||||
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
|
||||
|
||||
// SPA routes - serve index.html for client-side routing
|
||||
r.Get("/", serveIndex)
|
||||
r.Get("/devices", serveIndex)
|
||||
r.Get("/device/*", serveIndex)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
|
||||
log.Println("Starting device discovery...")
|
||||
|
||||
devices, err := discoveryService.DiscoverDevices(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Discovery failed: %v", err)
|
||||
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Found %d devices", len(devices))
|
||||
|
||||
for _, device := range devices {
|
||||
deviceID := device.Host // Use host as unique ID for now
|
||||
|
||||
// Skip if we already have this device
|
||||
if _, exists := app.Devices[deviceID]; exists {
|
||||
app.Devices[deviceID].LastSeen = time.Now()
|
||||
continue
|
||||
}
|
||||
|
||||
// Create new device connection
|
||||
clientConfig := &client.Config{
|
||||
Host: device.Host,
|
||||
Port: device.Port,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
soundTouchClient := client.NewClient(clientConfig)
|
||||
|
||||
// Get device info
|
||||
deviceInfo, err := soundTouchClient.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get device info for %s: %v", device.Host, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create device connection
|
||||
conn := &webtypes.DeviceConnection{
|
||||
Client: soundTouchClient,
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{
|
||||
IsConnected: false,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
// Initial status fetch asynchronously to avoid blocking discovery
|
||||
go app.UpdateDeviceStatus(deviceID, conn)
|
||||
|
||||
app.Devices[deviceID] = conn
|
||||
|
||||
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveBindAddr_PassThrough(t *testing.T) {
|
||||
// Inputs that don't match any local interface name must be returned
|
||||
// unchanged: empty string, hostnames, IPv4/IPv6 literals, and bogus
|
||||
// strings the user might have typed.
|
||||
tests := []string{
|
||||
"",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"192.168.1.5",
|
||||
"::1",
|
||||
"definitely-not-an-iface-xyz",
|
||||
}
|
||||
|
||||
for _, input := range tests {
|
||||
t.Run(quoted(input), func(t *testing.T) {
|
||||
got, err := resolveBindAddr(input)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got != input {
|
||||
t.Errorf("got %q, want %q (input should pass through unchanged)", got, input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveBindAddr_LoopbackInterface(t *testing.T) {
|
||||
loopback, expected, ok := findLoopbackWithSingleIPv4(t)
|
||||
if !ok {
|
||||
t.Skipf("no loopback interface with exactly one IPv4 address found")
|
||||
}
|
||||
|
||||
got, err := resolveBindAddr(loopback)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error resolving %q: %v", loopback, err)
|
||||
}
|
||||
|
||||
if got != expected {
|
||||
t.Errorf("got %q, want %q for loopback interface %q", got, expected, loopback)
|
||||
}
|
||||
}
|
||||
|
||||
// findLoopbackWithSingleIPv4 returns the name of a loopback interface and the
|
||||
// single IPv4 address attached to it. If the host has multiple loopback
|
||||
// interfaces or the loopback has zero or several IPv4 addresses, it returns
|
||||
// ok=false so the caller can skip the test rather than fail on an environment
|
||||
// quirk.
|
||||
func findLoopbackWithSingleIPv4(t *testing.T) (name, addr string, ok bool) {
|
||||
t.Helper()
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
t.Fatalf("net.Interfaces: %v", err)
|
||||
}
|
||||
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, addrErr := iface.Addrs()
|
||||
if addrErr != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var ipv4s []string
|
||||
|
||||
for _, a := range addrs {
|
||||
if ipnet, isIPNet := a.(*net.IPNet); isIPNet {
|
||||
if v4 := ipnet.IP.To4(); v4 != nil {
|
||||
ipv4s = append(ipv4s, v4.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ipv4s) == 1 {
|
||||
return iface.Name, ipv4s[0], true
|
||||
}
|
||||
}
|
||||
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func TestDefaultDiscoveryInterface(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawInterface string
|
||||
rawBind string
|
||||
resolvedBind string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "explicit interface wins over bind-derived default",
|
||||
rawInterface: "eth1",
|
||||
rawBind: "eth0",
|
||||
resolvedBind: "192.168.1.5",
|
||||
want: "eth1",
|
||||
},
|
||||
{
|
||||
name: "derive from --bind when --bind was an interface name",
|
||||
rawInterface: "",
|
||||
rawBind: "eth0",
|
||||
resolvedBind: "192.168.1.5",
|
||||
want: "eth0",
|
||||
},
|
||||
{
|
||||
name: "no derivation when --bind was an IP literal",
|
||||
rawInterface: "",
|
||||
rawBind: "192.168.1.5",
|
||||
resolvedBind: "192.168.1.5",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "no derivation when --bind was a hostname (pass-through)",
|
||||
rawInterface: "",
|
||||
rawBind: "localhost",
|
||||
resolvedBind: "localhost",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "both empty stays empty (auto-pick)",
|
||||
rawInterface: "",
|
||||
rawBind: "",
|
||||
resolvedBind: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "explicit interface alone, --bind empty",
|
||||
rawInterface: "eth1",
|
||||
rawBind: "",
|
||||
resolvedBind: "",
|
||||
want: "eth1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := defaultDiscoveryInterface(tc.rawInterface, tc.rawBind, tc.resolvedBind)
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q (rawInterface=%q rawBind=%q resolvedBind=%q)",
|
||||
got, tc.want, tc.rawInterface, tc.rawBind, tc.resolvedBind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func quoted(s string) string {
|
||||
if s == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(s, "/", "_")
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func withChiParams(r *http.Request, params map[string]string) *http.Request {
|
||||
rctx := chi.NewRouteContext()
|
||||
for k, v := range params {
|
||||
rctx.URLParams.Add(k, v)
|
||||
}
|
||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||
}
|
||||
|
||||
func TestSPARouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expectedStatus int
|
||||
expectedHTML bool
|
||||
}{
|
||||
{
|
||||
name: "root path serves HTML",
|
||||
path: "/",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
{
|
||||
name: "device path serves HTML",
|
||||
path: "/device/test-device",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
{
|
||||
name: "arbitrary path serves HTML",
|
||||
path: "/some/random/path",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedHTML: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Simulate SPA routing handler
|
||||
spaHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
// If it's an API route, let it pass through
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/ws") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the SPA index.html content (simulated)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">SPA Content</div>
|
||||
</body>
|
||||
</html>`))
|
||||
}
|
||||
|
||||
spaHandler(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.expectedHTML {
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/html") {
|
||||
t.Errorf("Expected HTML content type, got %s", contentType)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "<!doctype html>") {
|
||||
t.Errorf("Expected HTML content, got: %s", body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIEndpoints(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
expectedStatus int
|
||||
expectedJSON bool
|
||||
}{
|
||||
{
|
||||
name: "devices API returns JSON",
|
||||
path: "/api/devices",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedJSON: true,
|
||||
},
|
||||
{
|
||||
name: "discover API accepts POST",
|
||||
path: "/api/discover",
|
||||
method: "POST",
|
||||
expectedStatus: http.StatusOK,
|
||||
expectedJSON: true,
|
||||
},
|
||||
{
|
||||
name: "device API with ID",
|
||||
path: "/api/device/test-device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound, // Device won't exist in test
|
||||
expectedJSON: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
switch tt.path {
|
||||
case "/api/devices":
|
||||
app.HandleAPIDevices(w, req)
|
||||
case "/api/discover":
|
||||
app.HandleAPIDiscover(w, req)
|
||||
default:
|
||||
if strings.HasPrefix(tt.path, "/api/device/") {
|
||||
deviceID := strings.TrimPrefix(tt.path, "/api/device/")
|
||||
req = withChiParams(req, map[string]string{"id": deviceID})
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
|
||||
}
|
||||
|
||||
if tt.expectedJSON {
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
// Validate JSON response structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIResponseFormat(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleAPIDevices(w, req)
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode JSON response: %v", err)
|
||||
}
|
||||
|
||||
// Check API response structure
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got success=%v", response.Success)
|
||||
}
|
||||
|
||||
if response.Data == nil {
|
||||
t.Errorf("Expected data field to be present")
|
||||
}
|
||||
|
||||
// Data should be an empty map for no devices
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Expected data to be a map, got %T", response.Data)
|
||||
}
|
||||
|
||||
if len(dataMap) != 0 {
|
||||
t.Errorf("Expected empty device map, got %d devices", len(dataMap))
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlAPIValidation(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
method string
|
||||
body string
|
||||
expectedStatus int
|
||||
chiParams map[string]string
|
||||
}{
|
||||
{
|
||||
name: "missing device ID",
|
||||
path: "/api/control//play",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid control path",
|
||||
path: "/api/control/device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "unknown action",
|
||||
path: "/api/control/nonexistent/invalid",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
chiParams: map[string]string{"id": "nonexistent", "action": "invalid"},
|
||||
},
|
||||
{
|
||||
name: "nonexistent device",
|
||||
path: "/api/control/nonexistent/play",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
chiParams: map[string]string{"id": "nonexistent", "action": "play"},
|
||||
},
|
||||
{
|
||||
name: "unknown action with valid device",
|
||||
path: "/api/control/testdevice/unknownaction",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
chiParams: map[string]string{"id": "testdevice", "action": "unknownaction"},
|
||||
},
|
||||
}
|
||||
|
||||
// Add a mock device for testing unknown action validation
|
||||
mockDevice := &webtypes.DeviceConnection{
|
||||
Client: nil,
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{IsConnected: true},
|
||||
}
|
||||
app.Devices["testdevice"] = mockDevice
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req *http.Request
|
||||
if tt.body != "" {
|
||||
req = httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(tt.method, tt.path, nil)
|
||||
}
|
||||
if tt.chiParams != nil {
|
||||
req = withChiParams(req, tt.chiParams)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.HandleAPIControl(w, req)
|
||||
|
||||
if w.Code != tt.expectedStatus {
|
||||
t.Errorf("Test %s: Expected status %d, got %d. Response: %s", tt.name, tt.expectedStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Validate error response format
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
}
|
||||
|
||||
if response.Success {
|
||||
t.Errorf("Expected success=false for error case, got success=true")
|
||||
}
|
||||
|
||||
if response.Error == "" {
|
||||
t.Errorf("Expected error message, got empty string")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketUpgrade(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
// Test WebSocket upgrade request
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
req.Header.Set("Sec-WebSocket-Version", "13")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// The actual WebSocket upgrade will fail in test environment,
|
||||
// but we can check that the handler exists and accepts the request
|
||||
app.HandleWebSocket(w, req)
|
||||
|
||||
// In a real test environment, this would fail with a websocket upgrade error
|
||||
// We're just checking the handler doesn't panic and processes the request
|
||||
}
|
||||
|
||||
func TestJSONAPIConsistency(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
|
||||
endpoints := []string{
|
||||
"/api/devices",
|
||||
"/api/device/test",
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
t.Run("JSON consistency for "+endpoint, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", endpoint, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
switch endpoint {
|
||||
case "/api/devices":
|
||||
app.HandleAPIDevices(w, req)
|
||||
default:
|
||||
if strings.HasPrefix(endpoint, "/api/device/") {
|
||||
deviceID := strings.TrimPrefix(endpoint, "/api/device/")
|
||||
req = withChiParams(req, map[string]string{"id": deviceID})
|
||||
app.HandleAPIDevice(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
// All API endpoints should return JSON
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Endpoint %s should return JSON, got %s", endpoint, contentType)
|
||||
}
|
||||
|
||||
// All responses should follow APIResponse structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Endpoint %s returned invalid JSON: %v", endpoint, err)
|
||||
}
|
||||
|
||||
// Response should have either data or error
|
||||
if response.Success && response.Data == nil {
|
||||
t.Errorf("Endpoint %s: success response should have data", endpoint)
|
||||
}
|
||||
if !response.Success && response.Error == "" {
|
||||
t.Errorf("Endpoint %s: error response should have error message", endpoint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 80 56" style="enable-background:new 0 0 80 56;" xml:space="preserve">
|
||||
<title>Artboard Copy 9</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Page-1">
|
||||
<g id="Artboard-Copy-9">
|
||||
<path id="TI_Badge_Black-Copy-2" d="M63.9,27.7c0-0.3-0.2-0.5-0.5-0.5h-2.1c-0.1,0-0.2-0.1-0.2-0.2V16.8c0-0.1,0.1-0.2,0.2-0.2
|
||||
h1.8c0.3,0,0.5-0.2,0.5-0.5v-2.3c0-0.3-0.2-0.5-0.5-0.5h-7.6c-0.3,0-0.5,0.2-0.5,0.5v2.3c0,0.3,0.2,0.5,0.5,0.5h1.8
|
||||
c0.1,0,0.2,0.1,0.2,0.2v10.1c0,0.1-0.1,0.2-0.2,0.2h-2.1c-0.3,0-0.5,0.2-0.5,0.5V30c0,0.3,0.2,0.5,0.5,0.5h8.1
|
||||
c0.3,0,0.5-0.2,0.5-0.5V27.7z M38.2,17H4c-0.2,0-0.3,0.1-0.3,0.3v33.8c0,0.2,0.1,0.3,0.3,0.3h33.8c0.2,0,0.3-0.1,0.3-0.3V17z
|
||||
M80,2.8V41c0,1-0.8,1.8-1.8,1.8H41.8v10.5c0,1-0.8,1.8-1.8,1.8H1.8c-1,0-1.8-0.8-1.8-1.8V15.1c0-1,0.8-1.8,1.8-1.8h36.3V2.8
|
||||
C38.2,1.8,39,1,40,1h38.2C79.2,1,80,1.8,80,2.8z M14.8,28.5v-2.5c0-0.3,0.2-0.5,0.5-0.5h10.1c0.3,0,0.5,0.2,0.5,0.5v2.5
|
||||
c0,0.3-0.2,0.5-0.5,0.5h-3.1c-0.1,0-0.2,0.1-0.2,0.2v13c0,0.3-0.2,0.5-0.5,0.5h-2.5c-0.3,0-0.5-0.2-0.5-0.5v-13
|
||||
c0-0.1-0.1-0.2-0.2-0.2h-3.1C15,29,14.8,28.8,14.8,28.5L14.8,28.5z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 80 56" style="enable-background:new 0 0 80 56;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFFFFF;}
|
||||
</style>
|
||||
<title>Artboard Copy 9</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Page-1">
|
||||
<g id="Artboard-Copy-9">
|
||||
<path id="TI_Badge_Black-Copy-2" class="st0" d="M63.9,27.7c0-0.3-0.2-0.5-0.5-0.5h-2.1c-0.1,0-0.2-0.1-0.2-0.2V16.8
|
||||
c0-0.1,0.1-0.2,0.2-0.2h1.8c0.3,0,0.5-0.2,0.5-0.5v-2.3c0-0.3-0.2-0.5-0.5-0.5h-7.6c-0.3,0-0.5,0.2-0.5,0.5v2.3
|
||||
c0,0.3,0.2,0.5,0.5,0.5h1.8c0.1,0,0.2,0.1,0.2,0.2v10.1c0,0.1-0.1,0.2-0.2,0.2h-2.1c-0.3,0-0.5,0.2-0.5,0.5V30
|
||||
c0,0.3,0.2,0.5,0.5,0.5h8.1c0.3,0,0.5-0.2,0.5-0.5L63.9,27.7L63.9,27.7z M38.2,17H4c-0.2,0-0.3,0.1-0.3,0.3v33.8
|
||||
c0,0.2,0.1,0.3,0.3,0.3h33.8c0.2,0,0.3-0.1,0.3-0.3V17H38.2z M80,2.8V41c0,1-0.8,1.8-1.8,1.8H41.8v10.5c0,1-0.8,1.8-1.8,1.8H1.8
|
||||
c-1,0-1.8-0.8-1.8-1.8V15.1c0-1,0.8-1.8,1.8-1.8h36.3V2.8C38.2,1.8,39,1,40,1h38.2C79.2,1,80,1.8,80,2.8z M14.8,28.5V26
|
||||
c0-0.3,0.2-0.5,0.5-0.5h10.1c0.3,0,0.5,0.2,0.5,0.5v2.5c0,0.3-0.2,0.5-0.5,0.5h-3.1c-0.1,0-0.2,0.1-0.2,0.2v13
|
||||
c0,0.3-0.2,0.5-0.5,0.5h-2.5c-0.3,0-0.5-0.2-0.5-0.5v-13c0-0.1-0.1-0.2-0.2-0.2h-3.1C15,29,14.8,28.8,14.8,28.5L14.8,28.5z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,200 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="/static/css/app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" onclick="showPage('devices')">
|
||||
<i class="bi bi-speaker"></i>
|
||||
SoundTouch Control
|
||||
</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="showPage('devices')"
|
||||
title="Home"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link tunein-nav-link"
|
||||
href="#"
|
||||
onclick="showPage('tunein')"
|
||||
title="TuneIn Browse"
|
||||
>
|
||||
<img
|
||||
src="/static/img/tunein-mono.svg"
|
||||
alt="TuneIn"
|
||||
class="tunein-nav-icon"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="discoverDevices()"
|
||||
title="Discover Devices"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
</a>
|
||||
<button
|
||||
class="theme-toggle nav-link"
|
||||
onclick="toggleTheme()"
|
||||
title="Toggle Dark Mode"
|
||||
>
|
||||
<i id="theme-icon" class="bi bi-moon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<!-- Device List Page -->
|
||||
<div id="devices-page" class="page active">
|
||||
<div
|
||||
class="d-flex justify-content-between align-items-center mb-4"
|
||||
>
|
||||
<h2>Your SoundTouch Devices</h2>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Discover Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="devices-loading" class="loading-spinner"></div>
|
||||
|
||||
<div id="devices-list" class="row">
|
||||
<!-- Device cards will be inserted here by JavaScript -->
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="no-devices"
|
||||
style="display: none"
|
||||
class="text-center py-5"
|
||||
>
|
||||
<i class="bi bi-speaker display-1 text-muted"></i>
|
||||
<h4 class="mt-3">No Devices Found</h4>
|
||||
<p class="text-muted">
|
||||
Click "Discover Devices" to search for SoundTouch
|
||||
speakers on your network.
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Start Discovery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TuneIn Browse Page -->
|
||||
<div id="tunein-page" class="page">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
|
||||
</div>
|
||||
|
||||
<div class="tunein-search-bar mb-3">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="tunein-search-input"
|
||||
class="form-control"
|
||||
placeholder="Search stations, podcasts..."
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="tuneInBrowse()"
|
||||
title="Browse top level"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
|
||||
<!-- filled by JavaScript -->
|
||||
</nav>
|
||||
|
||||
<div id="tunein-results">
|
||||
<!-- filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Control Page -->
|
||||
<div id="device-page" class="page">
|
||||
<div class="back-button">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="showPage('devices')"
|
||||
>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
Back to Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="device-content">
|
||||
<!-- Device control content will be inserted here by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container text-center">
|
||||
<small>
|
||||
SoundTouch Web Control Interface -
|
||||
<a
|
||||
href="https://github.com/gesellix/Bose-SoundTouch"
|
||||
target="_blank"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
Open Source Project
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container"></div>
|
||||
|
||||
<!-- Device picker for TuneIn playback -->
|
||||
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title" id="devicePickerLabel">
|
||||
<i class="bi bi-speaker me-2"></i>Play on device
|
||||
</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-2" id="devicePickerList">
|
||||
<!-- device buttons filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Application JavaScript -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
// Package webtypes contains type definitions for the SoundTouch web UI.
|
||||
package webtypes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
// SoundTouchClient defines the interface for SoundTouch client operations
|
||||
type SoundTouchClient interface {
|
||||
Play() error
|
||||
Pause() error
|
||||
Stop() error
|
||||
NextTrack() error
|
||||
PrevTrack() error
|
||||
SetVolume(level int) error
|
||||
SetBass(level int) error
|
||||
SelectPreset(id int) error
|
||||
SelectSource(source, account string) error
|
||||
SendKey(key string) error
|
||||
GetDeviceInfo() (*models.DeviceInfo, error)
|
||||
GetNowPlaying() (*models.NowPlaying, error)
|
||||
GetVolume() (*models.Volume, error)
|
||||
GetPresets() (*models.Presets, error)
|
||||
GetSources() (*models.Sources, error)
|
||||
GetBass() (*models.Bass, error)
|
||||
NewWebSocketClient(config interface{}) *client.WebSocketClient
|
||||
}
|
||||
|
||||
// DeviceConnection wraps a SoundTouch client with WebSocket connection
|
||||
type DeviceConnection struct {
|
||||
Client *client.Client
|
||||
WebSocket *client.WebSocketClient
|
||||
DeviceInfo *models.DeviceInfo
|
||||
LastSeen time.Time
|
||||
Status DeviceStatus
|
||||
}
|
||||
|
||||
// DeviceStatus represents the current device state
|
||||
type DeviceStatus struct {
|
||||
NowPlaying *models.NowPlaying `json:"nowPlaying,omitempty"`
|
||||
Volume *models.Volume `json:"volume,omitempty"`
|
||||
Presets *models.Presets `json:"presets,omitempty"`
|
||||
Sources *models.Sources `json:"sources,omitempty"`
|
||||
Bass *models.Bass `json:"bass,omitempty"`
|
||||
IsConnected bool `json:"isConnected"`
|
||||
LastActivity time.Time `json:"lastActivity"`
|
||||
}
|
||||
|
||||
// APIResponse is a standard JSON response wrapper
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// VolumeRequest represents a volume control request
|
||||
type VolumeRequest struct {
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
// BassRequest represents a bass control request
|
||||
type BassRequest struct {
|
||||
Level int `json:"level"`
|
||||
}
|
||||
|
||||
// WebSocketMessage represents messages sent over WebSocket
|
||||
type WebSocketMessage struct {
|
||||
Type string `json:"type"`
|
||||
DeviceID string `json:"deviceId,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Package types contains tests for type definitions.
|
||||
package webtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
|
||||
func TestAPIResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response APIResponse
|
||||
wantJSON string
|
||||
}{
|
||||
{
|
||||
name: "success response",
|
||||
response: APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "OK"},
|
||||
},
|
||||
wantJSON: `{"success":true,"data":{"message":"OK"}}`,
|
||||
},
|
||||
{
|
||||
name: "error response",
|
||||
response: APIResponse{
|
||||
Success: false,
|
||||
Error: "Something went wrong",
|
||||
},
|
||||
wantJSON: `{"success":false,"error":"Something went wrong"}`,
|
||||
},
|
||||
{
|
||||
name: "success with nil data",
|
||||
response: APIResponse{
|
||||
Success: true,
|
||||
},
|
||||
wantJSON: `{"success":true}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test that the struct fields are correctly set
|
||||
if tt.response.Success != (tt.name == "success response" || tt.name == "success with nil data") {
|
||||
t.Errorf("Expected success to match test case")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req VolumeRequest
|
||||
level int
|
||||
}{
|
||||
{"zero volume", VolumeRequest{Level: 0}, 0},
|
||||
{"mid volume", VolumeRequest{Level: 50}, 50},
|
||||
{"max volume", VolumeRequest{Level: 100}, 100},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.req.Level != tt.level {
|
||||
t.Errorf("Expected level %d, got %d", tt.level, tt.req.Level)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBassRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req BassRequest
|
||||
level int
|
||||
}{
|
||||
{"min bass", BassRequest{Level: -9}, -9},
|
||||
{"neutral bass", BassRequest{Level: 0}, 0},
|
||||
{"max bass", BassRequest{Level: 9}, 9},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.req.Level != tt.level {
|
||||
t.Errorf("Expected level %d, got %d", tt.level, tt.req.Level)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSocketMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg WebSocketMessage
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "devices message",
|
||||
msg: WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: map[string]interface{}{"device1": "data"},
|
||||
},
|
||||
wantType: "devices",
|
||||
},
|
||||
{
|
||||
name: "status update message",
|
||||
msg: WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: "device1",
|
||||
Data: DeviceStatus{IsConnected: true},
|
||||
},
|
||||
wantType: "status_update",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.msg.Type != tt.wantType {
|
||||
t.Errorf("Expected type %s, got %s", tt.wantType, tt.msg.Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceConnection(t *testing.T) {
|
||||
deviceInfo := &models.DeviceInfo{
|
||||
Name: "Test Speaker",
|
||||
Type: "SoundTouch 30",
|
||||
NetworkInfo: []models.NetworkInfo{
|
||||
{MacAddress: "TEST123", IPAddress: "192.168.1.100"},
|
||||
},
|
||||
}
|
||||
|
||||
nowPlaying := &models.NowPlaying{
|
||||
Track: "Test Track",
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
PlayStatus: models.PlayStatusPlaying,
|
||||
Source: "SPOTIFY",
|
||||
}
|
||||
|
||||
volume := &models.Volume{
|
||||
ActualVolume: 50,
|
||||
MuteEnabled: false,
|
||||
}
|
||||
|
||||
conn := &DeviceConnection{
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: DeviceStatus{
|
||||
NowPlaying: nowPlaying,
|
||||
Volume: volume,
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("device connection fields", func(t *testing.T) {
|
||||
if conn.DeviceInfo.Name != "Test Speaker" {
|
||||
t.Errorf("Expected device name 'Test Speaker', got '%s'", conn.DeviceInfo.Name)
|
||||
}
|
||||
|
||||
if conn.Status.NowPlaying.Track != "Test Track" {
|
||||
t.Errorf("Expected track 'Test Track', got '%s'", conn.Status.NowPlaying.Track)
|
||||
}
|
||||
|
||||
if conn.Status.Volume.ActualVolume != 50 {
|
||||
t.Errorf("Expected volume 50, got %d", conn.Status.Volume.ActualVolume)
|
||||
}
|
||||
|
||||
if !conn.Status.IsConnected {
|
||||
t.Error("Expected device to be connected")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeviceStatus(t *testing.T) {
|
||||
status := DeviceStatus{
|
||||
NowPlaying: &models.NowPlaying{
|
||||
Track: "Test Track",
|
||||
PlayStatus: models.PlayStatusPlaying,
|
||||
},
|
||||
Volume: &models.Volume{
|
||||
ActualVolume: 75,
|
||||
MuteEnabled: false,
|
||||
},
|
||||
Bass: &models.Bass{
|
||||
ActualBass: 3,
|
||||
},
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
t.Run("device status fields", func(t *testing.T) {
|
||||
if status.NowPlaying == nil {
|
||||
t.Error("Expected now playing to be set")
|
||||
}
|
||||
|
||||
if status.Volume == nil {
|
||||
t.Error("Expected volume to be set")
|
||||
}
|
||||
|
||||
if status.Bass == nil {
|
||||
t.Error("Expected bass to be set")
|
||||
}
|
||||
|
||||
if !status.IsConnected {
|
||||
t.Error("Expected device to be connected")
|
||||
}
|
||||
|
||||
if status.LastActivity.IsZero() {
|
||||
t.Error("Expected last activity to be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil fields", func(t *testing.T) {
|
||||
emptyStatus := DeviceStatus{}
|
||||
|
||||
if emptyStatus.NowPlaying != nil {
|
||||
t.Error("Expected now playing to be nil")
|
||||
}
|
||||
|
||||
if emptyStatus.Volume != nil {
|
||||
t.Error("Expected volume to be nil")
|
||||
}
|
||||
|
||||
if emptyStatus.IsConnected {
|
||||
t.Error("Expected device to be disconnected by default")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkAPIResponse(b *testing.B) {
|
||||
response := APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "OK"},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = response.Success
|
||||
_ = response.Data
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDeviceStatus(b *testing.B) {
|
||||
status := DeviceStatus{
|
||||
NowPlaying: &models.NowPlaying{Track: "Test Track"},
|
||||
Volume: &models.Volume{ActualVolume: 50},
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = status.IsConnected
|
||||
_ = status.NowPlaying.Track
|
||||
_ = status.Volume.ActualVolume
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkWebSocketMessage(b *testing.B) {
|
||||
msg := WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: "device1",
|
||||
Data: DeviceStatus{
|
||||
IsConnected: true,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = msg.Type
|
||||
_ = msg.DeviceID
|
||||
_ = msg.Data
|
||||
}
|
||||
}
|
||||
+293
-191
@@ -43,6 +43,92 @@ func parseHostPort(hostPort string, defaultPort int) (string, int) {
|
||||
return hostPort, defaultPort
|
||||
}
|
||||
|
||||
func parseFilters(eventFilter string) map[string]bool {
|
||||
validFilters := map[string]bool{
|
||||
"nowPlaying": true, "volume": true, "connection": true,
|
||||
"preset": true, "zone": true, "bass": true,
|
||||
"sdkInfo": true, "userActivity": true,
|
||||
}
|
||||
|
||||
if eventFilter == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
filters := make(map[string]bool)
|
||||
filterList := strings.Split(eventFilter, ",")
|
||||
|
||||
for _, f := range filterList {
|
||||
f = strings.TrimSpace(f)
|
||||
if !validFilters[f] {
|
||||
fmt.Printf("Invalid filter '%s'. Valid filters: nowPlaying, volume, connection, preset, zone, bass, sdkInfo, userActivity\n", f)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
filters[f] = true
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
func discoverDevice(discoverFlag bool, hostPort string, defaultPort int) (string, int, error) {
|
||||
if hostPort != "" && !discoverFlag {
|
||||
deviceHost, devicePort := parseHostPort(hostPort, defaultPort)
|
||||
fmt.Printf("Connecting to: %s:%d\n", deviceHost, devicePort)
|
||||
|
||||
return deviceHost, devicePort, nil
|
||||
}
|
||||
|
||||
fmt.Println("Discovering SoundTouch devices...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cfg := &config.Config{
|
||||
DiscoveryTimeout: 10 * time.Second,
|
||||
CacheEnabled: false,
|
||||
}
|
||||
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
devices, err := discoveryService.DiscoverDevices(ctx)
|
||||
if err != nil || len(devices) == 0 {
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("discovery failed: %w", err)
|
||||
}
|
||||
|
||||
return "", 0, fmt.Errorf("no SoundTouch devices found")
|
||||
}
|
||||
|
||||
device := devices[0]
|
||||
fmt.Printf("Found %d device(s), connecting to: %s (%s:%d)\n",
|
||||
len(devices), device.Name, device.Host, device.Port)
|
||||
|
||||
return device.Host, device.Port, nil
|
||||
}
|
||||
|
||||
func setupWebSocket(soundTouchClient *client.Client, reconnect, verbose bool) *client.WebSocketClient {
|
||||
wsConfig := &client.WebSocketConfig{
|
||||
ReconnectInterval: 5 * time.Second,
|
||||
MaxReconnectAttempts: 0, // Unlimited if reconnect enabled
|
||||
PingInterval: 30 * time.Second,
|
||||
PongTimeout: 10 * time.Second,
|
||||
ReadBufferSize: 2048,
|
||||
WriteBufferSize: 2048,
|
||||
}
|
||||
|
||||
if verbose {
|
||||
wsConfig.Logger = &VerboseLogger{}
|
||||
} else {
|
||||
// Use a silent logger when not verbose
|
||||
wsConfig.Logger = &SilentLogger{}
|
||||
}
|
||||
|
||||
if !reconnect {
|
||||
wsConfig.MaxReconnectAttempts = 1
|
||||
}
|
||||
|
||||
return soundTouchClient.NewWebSocketClient(wsConfig)
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
host = flag.String("host", "", "SoundTouch device host/IP address (can include port like host:8090)")
|
||||
@@ -64,69 +150,13 @@ func main() {
|
||||
}
|
||||
|
||||
// Validate filter if provided
|
||||
validFilters := map[string]bool{
|
||||
"nowPlaying": true, "volume": true, "connection": true,
|
||||
"preset": true, "zone": true, "bass": true,
|
||||
}
|
||||
|
||||
var filters map[string]bool
|
||||
if *eventFilter != "" {
|
||||
filters = make(map[string]bool)
|
||||
|
||||
filterList := strings.Split(*eventFilter, ",")
|
||||
for _, f := range filterList {
|
||||
f = strings.TrimSpace(f)
|
||||
if !validFilters[f] {
|
||||
fmt.Printf("Invalid filter '%s'. Valid filters: nowPlaying, volume, connection, preset, zone, bass\n", f)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
filters[f] = true
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
deviceHost string
|
||||
devicePort int
|
||||
)
|
||||
filters := parseFilters(*eventFilter)
|
||||
|
||||
// Discover devices if no host specified or discover flag used
|
||||
|
||||
if *host == "" || *discover {
|
||||
fmt.Println("Discovering SoundTouch devices...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create unified discovery service
|
||||
cfg := &config.Config{
|
||||
DiscoveryTimeout: 10 * time.Second,
|
||||
CacheEnabled: false,
|
||||
}
|
||||
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
devices, err := discoveryService.DiscoverDevices(ctx)
|
||||
if err != nil {
|
||||
fmt.Printf("Discovery failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(devices) == 0 {
|
||||
fmt.Println("No SoundTouch devices found")
|
||||
return
|
||||
}
|
||||
|
||||
// Use first discovered device
|
||||
device := devices[0]
|
||||
deviceHost = device.Host
|
||||
devicePort = device.Port
|
||||
|
||||
fmt.Printf("Found %d device(s), connecting to: %s (%s:%d)\n",
|
||||
len(devices), device.Name, device.Host, device.Port)
|
||||
} else {
|
||||
// Parse provided host
|
||||
deviceHost, devicePort = parseHostPort(*host, *port)
|
||||
fmt.Printf("Connecting to: %s:%d\n", deviceHost, devicePort)
|
||||
deviceHost, devicePort, err := discoverDevice(*discover, *host, *port)
|
||||
if err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Create client
|
||||
@@ -156,34 +186,23 @@ func main() {
|
||||
deviceInfo.Name, deviceInfo.Type, macAddress)
|
||||
|
||||
// Create WebSocket client
|
||||
wsConfig := &client.WebSocketConfig{
|
||||
ReconnectInterval: 5 * time.Second,
|
||||
MaxReconnectAttempts: 0, // Unlimited if reconnect enabled
|
||||
PingInterval: 30 * time.Second,
|
||||
PongTimeout: 10 * time.Second,
|
||||
ReadBufferSize: 2048,
|
||||
WriteBufferSize: 2048,
|
||||
}
|
||||
|
||||
if *verbose {
|
||||
wsConfig.Logger = &VerboseLogger{}
|
||||
}
|
||||
|
||||
if !*reconnect {
|
||||
wsConfig.MaxReconnectAttempts = 1
|
||||
}
|
||||
|
||||
wsClient := soundTouchClient.NewWebSocketClient(wsConfig)
|
||||
wsClient := setupWebSocket(soundTouchClient, *reconnect, *verbose)
|
||||
|
||||
// Set up event handlers
|
||||
setupEventHandlers(wsClient, filters, *verbose)
|
||||
|
||||
// Set up special message handler
|
||||
wsClient.OnSpecialMessage(func(message *models.SpecialMessage) {
|
||||
handleSpecialMessage(message, filters, *verbose)
|
||||
})
|
||||
|
||||
// Connect to WebSocket
|
||||
fmt.Println("Connecting to WebSocket...")
|
||||
|
||||
err = wsClient.ConnectWithConfig(wsConfig)
|
||||
err = wsClient.Connect()
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to connect to WebSocket: %v\n", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -243,150 +262,224 @@ func main() {
|
||||
fmt.Println("Disconnected successfully")
|
||||
}
|
||||
|
||||
func handleNowPlaying(event *models.NowPlayingUpdatedEvent, verbose bool) {
|
||||
fmt.Printf("\n🎵 Now Playing Update [%s]:\n", event.DeviceID)
|
||||
np := &event.NowPlaying
|
||||
|
||||
if np.IsEmpty() {
|
||||
fmt.Println(" ⏹️ Nothing playing")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf(" 🎵 %s\n", np.GetDisplayTitle())
|
||||
|
||||
if artist := np.GetDisplayArtist(); artist != "" {
|
||||
fmt.Printf(" 👤 %s\n", artist)
|
||||
}
|
||||
|
||||
if np.Album != "" {
|
||||
fmt.Printf(" 💿 %s\n", np.Album)
|
||||
}
|
||||
|
||||
fmt.Printf(" 📻 Source: %s\n", np.Source)
|
||||
fmt.Printf(" ▶️ Status: %s\n", np.PlayStatus.String())
|
||||
|
||||
if np.HasTimeInfo() {
|
||||
fmt.Printf(" ⏱️ Duration: %s\n", np.FormatDuration())
|
||||
}
|
||||
|
||||
if np.ShuffleSetting != "" {
|
||||
fmt.Printf(" 🔀 Shuffle: %s\n", np.ShuffleSetting.String())
|
||||
}
|
||||
|
||||
if np.RepeatSetting != "" {
|
||||
fmt.Printf(" 🔁 Repeat: %s\n", np.RepeatSetting.String())
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Raw Source: %s, Account: %s\n", np.Source, np.SourceAccount)
|
||||
|
||||
if np.Art != nil && np.Art.URL != "" {
|
||||
fmt.Printf(" 🖼️ Artwork: %s\n", np.Art.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleVolume(event *models.VolumeUpdatedEvent, verbose bool) {
|
||||
vol := &event.Volume
|
||||
fmt.Printf("\n🔊 Volume Update [%s]:\n", event.DeviceID)
|
||||
|
||||
if vol.IsMuted() {
|
||||
fmt.Println(" 🔇 Muted")
|
||||
} else {
|
||||
fmt.Printf(" 🔊 Level: %d\n", vol.ActualVolume)
|
||||
|
||||
if vol.TargetVolume != vol.ActualVolume {
|
||||
fmt.Printf(" 🎯 Target: %d\n", vol.TargetVolume)
|
||||
}
|
||||
|
||||
fmt.Printf(" 📊 %s\n", models.GetVolumeLevelName(vol.ActualVolume))
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Sync: %v\n", vol.IsVolumeSync())
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnection(event *models.ConnectionStateUpdatedEvent) {
|
||||
cs := &event.ConnectionState
|
||||
fmt.Printf("\n🌐 Connection Update [%s]:\n", event.DeviceID)
|
||||
|
||||
if cs.IsConnected() {
|
||||
fmt.Println(" ✅ Connected")
|
||||
} else {
|
||||
fmt.Printf(" ❌ State: %s\n", cs.State)
|
||||
}
|
||||
|
||||
if cs.Signal != "" {
|
||||
fmt.Printf(" 📶 Signal: %s\n", cs.GetSignalStrength())
|
||||
}
|
||||
}
|
||||
|
||||
func handlePreset(event *models.PresetUpdatedEvent, verbose bool) {
|
||||
presets := &event.Presets
|
||||
|
||||
deviceHeader := "\n📻 Presets Update"
|
||||
if event.DeviceID != "" {
|
||||
deviceHeader += fmt.Sprintf(" [%s]", event.DeviceID)
|
||||
}
|
||||
|
||||
fmt.Printf("%s:\n", deviceHeader)
|
||||
fmt.Printf(" 📻 Total presets: %d\n", len(presets.Preset))
|
||||
|
||||
for _, preset := range presets.Preset {
|
||||
fmt.Printf(" 📻 Preset %d:", preset.ID)
|
||||
|
||||
if preset.ContentItem != nil {
|
||||
fmt.Printf(" %s", preset.ContentItem.ItemName)
|
||||
fmt.Printf(" (%s)", preset.ContentItem.Source)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Raw presets data: %d total presets\n", len(presets.Preset))
|
||||
}
|
||||
}
|
||||
|
||||
func handleZone(event *models.ZoneUpdatedEvent) {
|
||||
zone := &event.Zone
|
||||
fmt.Printf("\n🏠 Zone Update [%s]:\n", event.DeviceID)
|
||||
fmt.Printf(" 👑 Master: %s\n", zone.Master)
|
||||
|
||||
if len(zone.Members) > 0 {
|
||||
fmt.Printf(" 👥 Members (%d):\n", len(zone.Members))
|
||||
|
||||
for i, member := range zone.Members {
|
||||
fmt.Printf(" %d. %s (%s)\n", i+1, member.DeviceID, member.IP)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" 👤 Single device (no zone)")
|
||||
}
|
||||
}
|
||||
|
||||
func handleBass(event *models.BassUpdatedEvent) {
|
||||
bass := &event.Bass
|
||||
fmt.Printf("\n🎵 Bass Update [%s]:\n", event.DeviceID)
|
||||
fmt.Printf(" 🎚️ Level: %d\n", bass.ActualBass)
|
||||
|
||||
if bass.TargetBass != bass.ActualBass {
|
||||
fmt.Printf(" 🎯 Target: %d\n", bass.TargetBass)
|
||||
}
|
||||
|
||||
levelDesc := "Neutral"
|
||||
if bass.ActualBass > 0 {
|
||||
levelDesc = "Boosted"
|
||||
} else if bass.ActualBass < 0 {
|
||||
levelDesc = "Reduced"
|
||||
}
|
||||
|
||||
fmt.Printf(" 📊 %s\n", levelDesc)
|
||||
}
|
||||
|
||||
func handleSpecialMessage(message *models.SpecialMessage, filters map[string]bool, verbose bool) {
|
||||
// Check if we should filter this message type
|
||||
if filters != nil {
|
||||
switch message.Type {
|
||||
case models.MessageTypeSdkInfo:
|
||||
if !filters["sdkInfo"] {
|
||||
return
|
||||
}
|
||||
case models.MessageTypeUserActivity:
|
||||
if !filters["userActivity"] {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch message.Type {
|
||||
case models.MessageTypeSdkInfo:
|
||||
if sdkInfo := message.GetSdkInfo(); sdkInfo != nil {
|
||||
fmt.Printf("\n📡 SDK Info:\n")
|
||||
fmt.Printf(" 📋 Server Version: %s\n", sdkInfo.ServerVersion)
|
||||
fmt.Printf(" 🔧 Server Build: %s\n", sdkInfo.ServerBuild)
|
||||
}
|
||||
case models.MessageTypeUserActivity:
|
||||
fmt.Printf("\n👤 User Activity [%s]\n", message.DeviceID)
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" ⏰ Timestamp: %s\n", message.Timestamp.Format("15:04:05"))
|
||||
}
|
||||
default:
|
||||
fmt.Printf("\n❓ Unknown Special Message: %s\n", message.String())
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Raw data: %s\n", string(message.RawData))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupEventHandlers(wsClient *client.WebSocketClient, filters map[string]bool, verbose bool) {
|
||||
// Now Playing events
|
||||
if filters == nil || filters["nowPlaying"] {
|
||||
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
||||
fmt.Printf("\n🎵 Now Playing Update [%s]:\n", event.DeviceID)
|
||||
np := &event.NowPlaying
|
||||
|
||||
if np.IsEmpty() {
|
||||
fmt.Println(" ⏹️ Nothing playing")
|
||||
} else {
|
||||
fmt.Printf(" 🎵 %s\n", np.GetDisplayTitle())
|
||||
|
||||
if artist := np.GetDisplayArtist(); artist != "" {
|
||||
fmt.Printf(" 👤 %s\n", artist)
|
||||
}
|
||||
|
||||
if np.Album != "" {
|
||||
fmt.Printf(" 💿 %s\n", np.Album)
|
||||
}
|
||||
|
||||
fmt.Printf(" 📻 Source: %s\n", np.Source)
|
||||
fmt.Printf(" ▶️ Status: %s\n", np.PlayStatus.String())
|
||||
|
||||
if np.HasTimeInfo() {
|
||||
fmt.Printf(" ⏱️ Duration: %s\n", np.FormatDuration())
|
||||
}
|
||||
|
||||
if np.ShuffleSetting != "" {
|
||||
fmt.Printf(" 🔀 Shuffle: %s\n", np.ShuffleSetting.String())
|
||||
}
|
||||
|
||||
if np.RepeatSetting != "" {
|
||||
fmt.Printf(" 🔁 Repeat: %s\n", np.RepeatSetting.String())
|
||||
}
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Raw Source: %s, Account: %s\n", np.Source, np.SourceAccount)
|
||||
|
||||
if np.Art != nil && np.Art.URL != "" {
|
||||
fmt.Printf(" 🖼️ Artwork: %s\n", np.Art.URL)
|
||||
}
|
||||
}
|
||||
handleNowPlaying(event, verbose)
|
||||
})
|
||||
}
|
||||
|
||||
// Volume events
|
||||
if filters == nil || filters["volume"] {
|
||||
wsClient.OnVolumeUpdated(func(event *models.VolumeUpdatedEvent) {
|
||||
vol := &event.Volume
|
||||
fmt.Printf("\n🔊 Volume Update [%s]:\n", event.DeviceID)
|
||||
|
||||
if vol.IsMuted() {
|
||||
fmt.Println(" 🔇 Muted")
|
||||
} else {
|
||||
fmt.Printf(" 🔊 Level: %d\n", vol.ActualVolume)
|
||||
|
||||
if vol.TargetVolume != vol.ActualVolume {
|
||||
fmt.Printf(" 🎯 Target: %d\n", vol.TargetVolume)
|
||||
}
|
||||
|
||||
fmt.Printf(" 📊 %s\n", models.GetVolumeLevelName(vol.ActualVolume))
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Sync: %v\n", vol.IsVolumeSync())
|
||||
}
|
||||
handleVolume(event, verbose)
|
||||
})
|
||||
}
|
||||
|
||||
// Connection state events
|
||||
if filters == nil || filters["connection"] {
|
||||
wsClient.OnConnectionState(func(event *models.ConnectionStateUpdatedEvent) {
|
||||
cs := &event.ConnectionState
|
||||
fmt.Printf("\n🌐 Connection Update [%s]:\n", event.DeviceID)
|
||||
|
||||
if cs.IsConnected() {
|
||||
fmt.Println(" ✅ Connected")
|
||||
} else {
|
||||
fmt.Printf(" ❌ State: %s\n", cs.State)
|
||||
}
|
||||
|
||||
if cs.Signal != "" {
|
||||
fmt.Printf(" 📶 Signal: %s\n", cs.GetSignalStrength())
|
||||
}
|
||||
handleConnection(event)
|
||||
})
|
||||
}
|
||||
|
||||
// Preset events
|
||||
if filters == nil || filters["preset"] {
|
||||
wsClient.OnPresetUpdated(func(event *models.PresetUpdatedEvent) {
|
||||
preset := &event.Preset
|
||||
fmt.Printf("\n📻 Preset Update [%s]:\n", event.DeviceID)
|
||||
fmt.Printf(" 📻 Preset: %d\n", preset.ID)
|
||||
|
||||
if preset.ContentItem != nil {
|
||||
fmt.Printf(" 🎵 %s\n", preset.ContentItem.ItemName)
|
||||
fmt.Printf(" 📻 Source: %s\n", preset.ContentItem.Source)
|
||||
}
|
||||
|
||||
if verbose {
|
||||
fmt.Printf(" 📱 Raw preset data: ID=%d\n", preset.ID)
|
||||
}
|
||||
handlePreset(event, verbose)
|
||||
})
|
||||
}
|
||||
|
||||
// Zone/Multiroom events
|
||||
if filters == nil || filters["zone"] {
|
||||
wsClient.OnZoneUpdated(func(event *models.ZoneUpdatedEvent) {
|
||||
zone := &event.Zone
|
||||
fmt.Printf("\n🏠 Zone Update [%s]:\n", event.DeviceID)
|
||||
fmt.Printf(" 👑 Master: %s\n", zone.Master)
|
||||
|
||||
if len(zone.Members) > 0 {
|
||||
fmt.Printf(" 👥 Members (%d):\n", len(zone.Members))
|
||||
|
||||
for i, member := range zone.Members {
|
||||
fmt.Printf(" %d. %s (%s)\n", i+1, member.DeviceID, member.IP)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" 👤 Single device (no zone)")
|
||||
}
|
||||
handleZone(event)
|
||||
})
|
||||
}
|
||||
|
||||
// Bass events
|
||||
if filters == nil || filters["bass"] {
|
||||
wsClient.OnBassUpdated(func(event *models.BassUpdatedEvent) {
|
||||
bass := &event.Bass
|
||||
fmt.Printf("\n🎵 Bass Update [%s]:\n", event.DeviceID)
|
||||
fmt.Printf(" 🎚️ Level: %d\n", bass.ActualBass)
|
||||
|
||||
if bass.TargetBass != bass.ActualBass {
|
||||
fmt.Printf(" 🎯 Target: %d\n", bass.TargetBass)
|
||||
}
|
||||
|
||||
levelDesc := "Neutral"
|
||||
if bass.ActualBass > 0 {
|
||||
levelDesc = "Boosted"
|
||||
} else if bass.ActualBass < 0 {
|
||||
levelDesc = "Reduced"
|
||||
}
|
||||
|
||||
fmt.Printf(" 📊 %s\n", levelDesc)
|
||||
handleBass(event)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -442,7 +535,7 @@ func printHelp() {
|
||||
fmt.Println(" Enable verbose logging")
|
||||
fmt.Println(" -filter string")
|
||||
fmt.Println(" Filter events by type (comma-separated):")
|
||||
fmt.Println(" nowPlaying, volume, connection, preset, zone, bass")
|
||||
fmt.Println(" nowPlaying, volume, connection, preset, zone, bass, sdkInfo, userActivity")
|
||||
fmt.Println(" -help")
|
||||
fmt.Println(" Show this help message")
|
||||
fmt.Println()
|
||||
@@ -466,6 +559,8 @@ func printHelp() {
|
||||
fmt.Println(" 📻 preset - Preset configuration changes")
|
||||
fmt.Println(" 🏠 zone - Multiroom zone changes")
|
||||
fmt.Println(" 🎚️ bass - Bass level changes")
|
||||
fmt.Println(" 📡 sdkInfo - SDK version information")
|
||||
fmt.Println(" 👤 userActivity - User interaction notifications")
|
||||
fmt.Println()
|
||||
fmt.Println("The tool will automatically reconnect if the connection is lost.")
|
||||
fmt.Println("Press Ctrl+C to stop monitoring.")
|
||||
@@ -478,3 +573,10 @@ func (v *VerboseLogger) Printf(format string, args ...interface{}) {
|
||||
timestamp := time.Now().Format("15:04:05")
|
||||
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// SilentLogger provides no-op WebSocket logging
|
||||
type SilentLogger struct{}
|
||||
|
||||
func (s *SilentLogger) Printf(_ string, _ ...interface{}) {
|
||||
// Do nothing - silent logging
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
accounts/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
interactions/
|
||||
parity_mismatches/
|
||||
patterns.json
|
||||
settings.json
|
||||
@@ -0,0 +1,104 @@
|
||||
// Package soundtouch provides a comprehensive Go library, CLI tool, and local service for controlling and emulating Bose SoundTouch devices.
|
||||
//
|
||||
// This project implements the complete Bose SoundTouch Web API, enabling programmatic control
|
||||
// of SoundTouch speakers including playback control, volume management, source selection,
|
||||
// multiroom zone management, and real-time event monitoring.
|
||||
//
|
||||
// It also provides a local service (`soundtouch-service`) that can emulate the Bose Cloud,
|
||||
// allowing for offline control and enhanced debugging through HTTP interaction recording.
|
||||
//
|
||||
// # Quick Start
|
||||
//
|
||||
// Install the library:
|
||||
//
|
||||
// go get github.com/gesellix/bose-soundtouch
|
||||
//
|
||||
// Basic usage example:
|
||||
//
|
||||
// package main
|
||||
//
|
||||
// import (
|
||||
// "fmt"
|
||||
// "log"
|
||||
//
|
||||
// "github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
// )
|
||||
//
|
||||
// func main() {
|
||||
// // Create a client for your SoundTouch device
|
||||
// config := &client.Config{
|
||||
// Host: "192.168.1.100",
|
||||
// Port: 8090,
|
||||
// }
|
||||
// client := client.NewClient(config)
|
||||
//
|
||||
// // Get device information
|
||||
// info, err := client.GetInfo()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// fmt.Printf("Device: %s\n", info.Name)
|
||||
//
|
||||
// // Control playback
|
||||
// err = client.Play()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// # SoundTouch Service
|
||||
//
|
||||
// The `soundtouch-service` provides several advanced features:
|
||||
//
|
||||
// - Bose Cloud Emulation: Allows speakers to work without an internet connection.
|
||||
// - HTTP Interaction Recording: Captures all traffic as IntelliJ-compatible .http files.
|
||||
// - Speaker Migration: Automated tools to redirect speakers to the local service.
|
||||
// - Web Interface: A management dashboard for proxy settings and speaker setup.
|
||||
//
|
||||
// Install the service:
|
||||
//
|
||||
// go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-service@latest
|
||||
//
|
||||
// # CLI Tool
|
||||
//
|
||||
// The package includes a comprehensive CLI tool for device control:
|
||||
//
|
||||
// # Install the CLI
|
||||
// go install github.com/gesellix/bose-soundtouch/cmd/soundtouch-cli@latest
|
||||
//
|
||||
// # Discover devices
|
||||
// soundtouch-cli discover devices
|
||||
//
|
||||
// # Control a device
|
||||
// soundtouch-cli --host 192.168.1.100 play start
|
||||
//
|
||||
// # Supported Features
|
||||
//
|
||||
// - ✅ Device Information & Capabilities
|
||||
// - ✅ Playback, Volume, Bass, and Balance Control
|
||||
// - ✅ Source Selection & Preset Management
|
||||
// - ✅ Real-time WebSocket Events
|
||||
// - ✅ Multiroom Zone Management
|
||||
// - ✅ Device Discovery (UPnP/SSDP and mDNS)
|
||||
// - ✅ Local Cloud Emulation (soundtouch-service)
|
||||
// - ✅ HTTP Traffic Recording & Sanitization
|
||||
// - ✅ Automated Speaker Migration & Revert
|
||||
//
|
||||
// # Package Structure
|
||||
//
|
||||
// - client: HTTP client for SoundTouch Web API
|
||||
// - discovery: Device discovery using UPnP/SSDP and mDNS
|
||||
// - models: Data structures for API requests/responses
|
||||
// - service: Core logic for the soundtouch-service (proxy, recording, setup)
|
||||
// - cmd/soundtouch-cli: Command-line interface tool
|
||||
// - cmd/soundtouch-service: Local cloud emulation service
|
||||
//
|
||||
// # Implementation Notes
|
||||
//
|
||||
// This project is an independent effort to preserve the functionality of Bose SoundTouch
|
||||
// devices and provide enhanced debugging and control capabilities. It is not
|
||||
// affiliated with or endorsed by Bose Corporation.
|
||||
//
|
||||
// For detailed API documentation, examples, and advanced usage patterns, visit:
|
||||
// https://pkg.go.dev/github.com/gesellix/bose-soundtouch
|
||||
package soundtouch
|
||||
@@ -0,0 +1,46 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
build:
|
||||
context: .
|
||||
target: soundtouch-service
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
volumes:
|
||||
- ./tests/integration/testdata:/app/data
|
||||
environment:
|
||||
- SPOTIFY_CLIENT_ID=mock-id
|
||||
- SPOTIFY_CLIENT_SECRET=mock-secret
|
||||
- SPOTIFY_TOKEN_URL=http://spotify-mock:8080/api/token
|
||||
- SPOTIFY_API_BASE=http://spotify-mock:8080
|
||||
- AMAZON_CLIENT_ID=mock-amazon-id
|
||||
- AMAZON_CLIENT_SECRET=mock-amazon-secret
|
||||
- AMAZON_TOKEN_URL=http://amazon-mock:8080/auth/o2/token
|
||||
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.3-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-spotify/main.go -port 8080
|
||||
ports:
|
||||
- "8081:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.3-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
command: go run ./cmd/mock-amazon/main.go -port 8080
|
||||
ports:
|
||||
- "8082:8080"
|
||||
networks:
|
||||
- soundtouch-test-net
|
||||
|
||||
networks:
|
||||
soundtouch-test-net:
|
||||
name: soundtouch-test-net
|
||||
@@ -0,0 +1,41 @@
|
||||
services:
|
||||
soundtouch-service:
|
||||
image: ghcr.io/gesellix/bose-soundtouch:${SOUNDTOUCH_VERSION:-latest}
|
||||
# build: .
|
||||
container_name: soundtouch-service
|
||||
# Linux only, required for discovery. Swarm requires host network at the task level.
|
||||
# network_mode: host
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "8443:8443"
|
||||
environment:
|
||||
- PORT=8000
|
||||
- HTTPS_PORT=8443
|
||||
- DATA_DIR=/app/data
|
||||
- LOG_PROXY_BODY=false
|
||||
- REDACT_PROXY_LOGS=true
|
||||
- RECORD_INTERACTIONS=true
|
||||
- DISCOVERY_INTERVAL=5m
|
||||
- SERVER_URL=http://${SOUNDTOUCH_HOSTNAME:-soundtouch.local}:8000
|
||||
- HTTPS_SERVER_URL=https://${SOUNDTOUCH_HOSTNAME:-soundtouch.local}:8443
|
||||
volumes:
|
||||
- soundtouch-data:/app/data
|
||||
# Use host volume for local development if preferred:
|
||||
# - ./data:/app/data
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.50'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
|
||||
volumes:
|
||||
soundtouch-data:
|
||||
# Named volumes are preferred in Swarm. For multi-node persistence,
|
||||
# consider using a volume driver like NFS or GlusterFS.
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,387 +0,0 @@
|
||||
# Bose SoundTouch Web API - Endpoints Overview
|
||||
|
||||
This document provides a comprehensive overview of the available API endpoints verified against the official Bose SoundTouch Web API v1.0 specification (January 7, 2026).
|
||||
|
||||
## Implementation Status Legend
|
||||
- ✅ **Implemented** - Fully implemented with tests and real device validation
|
||||
- ❌ **Missing** - Documented in official API but not implemented
|
||||
- 🔍 **Extra** - Implemented but not in official API v1.0 (may be newer version or undocumented)
|
||||
- ⚠️ **Different** - Implemented with different approach than official API
|
||||
|
||||
## API Basics
|
||||
|
||||
- **Protocol**: HTTP REST-like
|
||||
- **Data Format**: XML Request/Response
|
||||
- **Standard Port**: 8090
|
||||
- **Base URL**: `http://<device-ip>:8090/`
|
||||
- **Authentication**: No complex authentication required
|
||||
- **Real-time Updates**: WebSocket connection available
|
||||
|
||||
## Device Information
|
||||
|
||||
### GET /info ✅ **Implemented**
|
||||
Retrieves basic device information.
|
||||
|
||||
**Response XML Structure:**
|
||||
```xml
|
||||
<info deviceID="..." type="..." name="..." ...>
|
||||
<name>Device Name</name>
|
||||
<type>Device Type</type>
|
||||
<margeAccountUUID>UUID</margeAccountUUID>
|
||||
<components>...</components>
|
||||
</info>
|
||||
```
|
||||
|
||||
## Playback Control
|
||||
|
||||
### GET /now_playing ✅ **Implemented**
|
||||
Retrieves information about the currently playing music.
|
||||
|
||||
**Response XML Structure:**
|
||||
```xml
|
||||
<nowPlaying deviceID="..." source="...">
|
||||
<ContentItem source="..." type="..." location="..." sourceAccount="...">
|
||||
<itemName>Track Name</itemName>
|
||||
<containerArt>Album Art URL</containerArt>
|
||||
</ContentItem>
|
||||
<track>Track Name</track>
|
||||
<artist>Artist Name</artist>
|
||||
<album>Album Name</album>
|
||||
<stationName>Station Name</stationName>
|
||||
<art artImageStatus="...">Art URL</art>
|
||||
<playStatus>PLAY_STATE</playStatus>
|
||||
<shuffleSetting>...</shuffleSetting>
|
||||
<repeatSetting>...</repeatSetting>
|
||||
</nowPlaying>
|
||||
```
|
||||
|
||||
### POST /key ✅ **Implemented**
|
||||
Sends key commands to the device.
|
||||
|
||||
**Important**: Proper key simulation requires sending both press and release states:
|
||||
|
||||
**Request XML (Press + Release):**
|
||||
```xml
|
||||
<key state="press" sender="Gabbo">KEY_NAME</key>
|
||||
<key state="release" sender="Gabbo">KEY_NAME</key>
|
||||
```
|
||||
|
||||
**Available Keys:**
|
||||
|
||||
**Playback Controls:**
|
||||
- `PLAY` - Start playback
|
||||
- `PAUSE` - Pause current playback
|
||||
- `STOP` - Stop current playback
|
||||
- `PREV_TRACK` - Go to previous track
|
||||
- `NEXT_TRACK` - Go to next track
|
||||
|
||||
**Rating and Bookmark Controls:**
|
||||
- `THUMBS_UP` - Rate current content positively (Pandora, etc.)
|
||||
- `THUMBS_DOWN` - Rate current content negatively
|
||||
- `BOOKMARK` - Bookmark current content
|
||||
|
||||
**Power and System Controls:**
|
||||
- `POWER` - Toggle device power state
|
||||
- `MUTE` - Toggle mute state
|
||||
|
||||
**Volume Controls:**
|
||||
- `VOLUME_UP` - Increase volume
|
||||
- `VOLUME_DOWN` - Decrease volume
|
||||
|
||||
**Preset Controls:**
|
||||
- `PRESET_1` to `PRESET_6` - Select preset 1-6
|
||||
|
||||
**Input Controls:**
|
||||
- `AUX_INPUT` - Switch to auxiliary input
|
||||
|
||||
**Shuffle Controls:**
|
||||
- `SHUFFLE_OFF` - Turn shuffle mode off
|
||||
- `SHUFFLE_ON` - Turn shuffle mode on
|
||||
|
||||
**Repeat Controls:**
|
||||
- `REPEAT_OFF` - Turn repeat mode off
|
||||
- `REPEAT_ONE` - Repeat current track
|
||||
- `REPEAT_ALL` - Repeat all tracks in playlist
|
||||
|
||||
## Volume Control
|
||||
|
||||
### GET /volume ✅ **Implemented**
|
||||
Retrieves the current volume.
|
||||
|
||||
**Response XML:**
|
||||
```xml
|
||||
<volume deviceID="...">
|
||||
<targetvolume>50</targetvolume>
|
||||
<actualvolume>50</actualvolume>
|
||||
<muteenabled>false</muteenabled>
|
||||
</volume>
|
||||
```
|
||||
|
||||
### POST /volume ✅ **Implemented**
|
||||
Sets the volume.
|
||||
|
||||
**Request XML:**
|
||||
```xml
|
||||
<volume>50</volume>
|
||||
```
|
||||
|
||||
## Bass Settings
|
||||
|
||||
### GET /bass ✅ **Implemented**
|
||||
Retrieves the current bass settings.
|
||||
|
||||
**Response XML:**
|
||||
```xml
|
||||
<bass deviceID="...">
|
||||
<targetbass>0</targetbass>
|
||||
<actualbass>0</actualbass>
|
||||
</bass>
|
||||
```
|
||||
|
||||
### POST /bass ✅ **Implemented**
|
||||
Sets the bass settings (-9 to +9).
|
||||
|
||||
**Request XML:**
|
||||
```xml
|
||||
<bass>0</bass>
|
||||
```
|
||||
|
||||
## Source Management
|
||||
|
||||
### GET /sources ✅ **Implemented**
|
||||
Retrieves the available audio sources.
|
||||
|
||||
**Response XML:**
|
||||
```xml
|
||||
<sources deviceID="...">
|
||||
<sourceItem source="SPOTIFY" sourceAccount="..." status="READY" multiroomallowed="true">
|
||||
<itemName>Spotify</itemName>
|
||||
</sourceItem>
|
||||
<sourceItem source="BLUETOOTH" status="READY" multiroomallowed="false">
|
||||
<itemName>Bluetooth</itemName>
|
||||
</sourceItem>
|
||||
<!-- Additional sources -->
|
||||
</sources>
|
||||
```
|
||||
|
||||
**Typical Sources:**
|
||||
- `SPOTIFY`
|
||||
- `AMAZON`
|
||||
- `PANDORA`
|
||||
- `IHEARTRADIO`
|
||||
- `TUNEIN`
|
||||
- `BLUETOOTH`
|
||||
- `AUX`
|
||||
- `STORED_MUSIC`
|
||||
|
||||
### POST /select ✅ **Implemented**
|
||||
Selects an audio source.
|
||||
|
||||
**Request XML:**
|
||||
```xml
|
||||
<ContentItem source="SPOTIFY" sourceAccount="...">
|
||||
<itemName>Spotify</itemName>
|
||||
</ContentItem>
|
||||
```
|
||||
|
||||
## Preset Management
|
||||
|
||||
### GET /presets ✅ **Implemented**
|
||||
Retrieves the configured presets.
|
||||
|
||||
**Response XML:**
|
||||
```xml
|
||||
<presets deviceID="...">
|
||||
<preset id="1" createdOn="..." updatedOn="...">
|
||||
<ContentItem source="..." sourceAccount="..." location="...">
|
||||
<itemName>Preset Name</itemName>
|
||||
<containerArt>Art URL</containerArt>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
<!-- Additional presets -->
|
||||
</presets>
|
||||
```
|
||||
|
||||
### POST /presets ❌ **Not Supported**
|
||||
Creates or updates a preset.
|
||||
|
||||
**Status**: According to the official Bose SoundTouch API documentation, POST operations on `/presets` are marked as "N/A" - this endpoint officially does not support preset creation or modification via API.
|
||||
|
||||
**Alternative Methods**:
|
||||
- Use the official Bose SoundTouch mobile app
|
||||
- Use physical preset buttons on the device (long-press while content is playing)
|
||||
- Changes made via these methods will be visible through the GET endpoint
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### GET /getZone 🔄 **Planned**
|
||||
Retrieves multiroom zone information.
|
||||
|
||||
### POST /setZone 🔄 **Planned**
|
||||
Configures multiroom zones.
|
||||
|
||||
### GET /balance ✅ **Implemented**
|
||||
Retrieves balance settings (stereo devices).
|
||||
|
||||
### POST /balance ✅ **Implemented**
|
||||
Sets balance settings.
|
||||
|
||||
### GET /clockTime ✅ **Implemented**
|
||||
Retrieves the device time.
|
||||
|
||||
### POST /clockTime ✅ **Implemented**
|
||||
Sets the device time.
|
||||
|
||||
### GET /clockDisplay ✅ **Implemented**
|
||||
Retrieves clock display settings.
|
||||
|
||||
### POST /clockDisplay ✅ **Implemented**
|
||||
Configures the clock display.
|
||||
|
||||
## WebSocket Connection
|
||||
|
||||
### WebSocket / 🔄 **Planned**
|
||||
Establishes a persistent connection for live updates.
|
||||
|
||||
**Event Types:**
|
||||
- `nowPlayingUpdated`
|
||||
- `volumeUpdated`
|
||||
- `connectionStateUpdated`
|
||||
- `presetUpdated`
|
||||
|
||||
## Network and System
|
||||
|
||||
### GET /networkInfo ✅ **Implemented**
|
||||
Retrieves network information.
|
||||
|
||||
### GET /capabilities ✅ **Implemented**
|
||||
Retrieves device capabilities.
|
||||
|
||||
### GET /name 🔍 **Extra**
|
||||
Retrieves the device name.
|
||||
|
||||
**Note**: Official API only documents `POST /name` for setting device name. Our GET implementation appears to be an undocumented extension.
|
||||
|
||||
### POST /name ❌ **Missing**
|
||||
Sets the device name.
|
||||
|
||||
**Official Request Format:**
|
||||
```xml
|
||||
<name>$STRING</name>
|
||||
```
|
||||
|
||||
### GET /bassCapabilities ❌ **Missing**
|
||||
Checks if bass customization is supported on the device.
|
||||
|
||||
**Official Response Format:**
|
||||
```xml
|
||||
<bassCapabilities deviceID="$MACADDR">
|
||||
<bassAvailable>$BOOL</bassAvailable>
|
||||
<bassMin>$INT</bassMin>
|
||||
<bassMax>$INT</bassMax>
|
||||
<bassDefault>$INT</bassDefault>
|
||||
</bassCapabilities>
|
||||
```
|
||||
|
||||
### GET /trackInfo ❌ **Missing**
|
||||
Gets track information (appears to be duplicate of `/now_playing`).
|
||||
|
||||
**Note**: Official API documents this as separate endpoint but with identical response format to `/now_playing`.
|
||||
|
||||
### Zone Slave Management ⚠️ **Different Implementation**
|
||||
Our implementation uses high-level methods instead of official endpoints:
|
||||
- **Official**: `/addZoneSlave` (POST) - Add slave to zone
|
||||
- **Official**: `/removeZoneSlave` (POST) - Remove slave from zone
|
||||
- **Our Implementation**: `AddToZone()` and `RemoveFromZone()` methods via `/setZone`
|
||||
|
||||
**Status**: Functionally equivalent and arguably cleaner approach.
|
||||
|
||||
### Advanced Audio Controls ❌ **Missing**
|
||||
Professional/high-end device features (only available via `/capabilities` check):
|
||||
|
||||
#### `/audiodspcontrols` - GET/POST
|
||||
Access DSP settings including audio modes and video sync delay.
|
||||
|
||||
#### `/audioproducttonecontrols` - GET/POST
|
||||
Advanced bass and treble controls (beyond basic `/bass` endpoint).
|
||||
|
||||
#### `/audioproductlevelcontrols` - GET/POST
|
||||
Speaker level controls for front-center and rear-surround speakers.
|
||||
|
||||
### Clock and Network Endpoints 🔍 **Extra**
|
||||
These endpoints work with real hardware but are NOT in official API v1.0:
|
||||
- `GET/POST /clockTime` ✅ **Implemented** - Device time management
|
||||
- `GET/POST /clockDisplay` ✅ **Implemented** - Clock display settings
|
||||
- `GET /networkInfo` ✅ **Implemented** - Network information
|
||||
|
||||
### Balance Control 🔍 **Extra**
|
||||
- `GET/POST /balance` ✅ **Implemented** - Stereo balance adjustment
|
||||
|
||||
**Note**: Not documented in official API v1.0 but works with real devices.
|
||||
|
||||
## Coverage Summary
|
||||
|
||||
### Official API Coverage: 94%
|
||||
- **Total Official Endpoints**: 19
|
||||
- **Implemented**: 15 (79%)
|
||||
- **Missing Low-Impact**: 4 (21%)
|
||||
|
||||
### Feature Coverage: 100%
|
||||
- ✅ All essential user functionality implemented
|
||||
- ✅ All core device operations supported
|
||||
- ✅ Complete WebSocket event system
|
||||
- ✅ Full multiroom capabilities
|
||||
- 🔍 Additional features beyond official specification
|
||||
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API uses standard HTTP status codes:
|
||||
- `200 OK` - Successful request
|
||||
- `400 Bad Request` - Invalid request
|
||||
- `404 Not Found` - Endpoint or resource not found
|
||||
- `500 Internal Server Error` - Internal device error
|
||||
|
||||
## Example Implementation
|
||||
|
||||
```go
|
||||
// Example for a GET request
|
||||
func GetNowPlaying(deviceIP string) (*NowPlaying, error) {
|
||||
url := fmt.Sprintf("http://%s:8090/now_playing", deviceIP)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var nowPlaying NowPlaying
|
||||
err = xml.NewDecoder(resp.Body).Decode(&nowPlaying)
|
||||
return &nowPlaying, err
|
||||
}
|
||||
|
||||
// Example for a POST request
|
||||
func SendKey(deviceIP string, key string) error {
|
||||
url := fmt.Sprintf("http://%s:8090/key", deviceIP)
|
||||
xmlData := fmt.Sprintf(`<key state="press" sender="GoClient">%s</key>`, key)
|
||||
|
||||
resp, err := http.Post(url, "application/xml", strings.NewReader(xmlData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
1. **XML Namespace**: Most responses use no explicit XML namespace
|
||||
2. **Encoding**: UTF-8 is used for all XML documents
|
||||
3. **Timeouts**: Recommended timeout for HTTP requests: 10 seconds
|
||||
4. **Rate Limiting**: No explicit limits documented, but moderate usage recommended
|
||||
5. **Device Discovery**: Devices can be found via UPnP on the local network
|
||||
|
||||
## Reference
|
||||
|
||||
Based on the official Bose SoundTouch Web API documentation:
|
||||
https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf
|
||||
@@ -0,0 +1,809 @@
|
||||
# Navigation API Reference
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a complete API reference for the Bose SoundTouch navigation and station management functionality. For usage examples and workflows, see [NAVIGATION-GUIDE.md](NAVIGATION-GUIDE.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Client Methods](#client-methods)
|
||||
- [Models](#models)
|
||||
- [HTTP Endpoints](#http-endpoints)
|
||||
- [XML Schemas](#xml-schemas)
|
||||
- [Error Codes](#error-codes)
|
||||
|
||||
## Client Methods
|
||||
|
||||
### Navigation Methods
|
||||
|
||||
#### `Navigate(source, sourceAccount string, startItem, numItems int) (*models.NavigateResponse, error)`
|
||||
|
||||
Browse content within a source.
|
||||
|
||||
**Parameters:**
|
||||
- `source` (string, required): Content source identifier
|
||||
- Valid values: `"TUNEIN"`, `"PANDORA"`, `"SPOTIFY"`, `"STORED_MUSIC"`, `"BLUETOOTH"`, `"AUX"`
|
||||
- `sourceAccount` (string, optional): Account identifier for authenticated sources
|
||||
- `startItem` (int, required): Starting position (1-based index)
|
||||
- `numItems` (int, required): Number of items to retrieve
|
||||
|
||||
**Returns:**
|
||||
- `*models.NavigateResponse`: Navigation results with items and metadata
|
||||
- `error`: Error if request fails
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
response, err := client.Navigate("TUNEIN", "", 1, 25)
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `source` cannot be empty
|
||||
- `startItem` must be >= 1
|
||||
- `numItems` must be >= 1
|
||||
|
||||
---
|
||||
|
||||
#### `NavigateWithMenu(source, sourceAccount, menu, sort string, startItem, numItems int) (*models.NavigateResponse, error)`
|
||||
|
||||
Browse content with specific menu and sorting options (primarily for Pandora).
|
||||
|
||||
**Parameters:**
|
||||
- `source` (string, required): Content source identifier
|
||||
- `sourceAccount` (string, optional): Account identifier
|
||||
- `menu` (string, optional): Menu context (e.g., `"radioStations"`)
|
||||
- `sort` (string, optional): Sort order (e.g., `"dateCreated"`)
|
||||
- `startItem` (int, required): Starting position (1-based)
|
||||
- `numItems` (int, required): Number of items to retrieve
|
||||
|
||||
**Returns:**
|
||||
- `*models.NavigateResponse`: Navigation results
|
||||
- `error`: Error if request fails
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
response, err := client.NavigateWithMenu("PANDORA", "user123", "radioStations", "dateCreated", 1, 100)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `NavigateContainer(source, sourceAccount string, startItem, numItems int, containerItem *models.ContentItem) (*models.NavigateResponse, error)`
|
||||
|
||||
Browse into a specific container/directory.
|
||||
|
||||
**Parameters:**
|
||||
- `source` (string, required): Content source identifier
|
||||
- `sourceAccount` (string, optional): Account identifier
|
||||
- `startItem` (int, required): Starting position (1-based)
|
||||
- `numItems` (int, required): Number of items to retrieve
|
||||
- `containerItem` (*models.ContentItem, required): Container to browse into
|
||||
|
||||
**Returns:**
|
||||
- `*models.NavigateResponse`: Container contents
|
||||
- `error`: Error if request fails
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
response, err := client.NavigateContainer("STORED_MUSIC", "device/0", 1, 100, albumContentItem)
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `containerItem` cannot be nil
|
||||
- Container must have valid `Location` field
|
||||
|
||||
---
|
||||
|
||||
### Convenience Navigation Methods
|
||||
|
||||
#### `GetTuneInStations(sourceAccount string) (*models.NavigateResponse, error)`
|
||||
|
||||
Browse TuneIn radio stations.
|
||||
|
||||
**Parameters:**
|
||||
- `sourceAccount` (string, optional): TuneIn account (usually empty)
|
||||
|
||||
**Returns:**
|
||||
- `*models.NavigateResponse`: TuneIn stations and content
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
stations, err := client.GetTuneInStations("")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `GetPandoraStations(sourceAccount string) (*models.NavigateResponse, error)`
|
||||
|
||||
Browse Pandora radio stations with proper sorting.
|
||||
|
||||
**Parameters:**
|
||||
- `sourceAccount` (string, required): Pandora user account identifier
|
||||
|
||||
**Returns:**
|
||||
- `*models.NavigateResponse`: Pandora stations sorted by creation date
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
stations, err := client.GetPandoraStations("user123")
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `sourceAccount` cannot be empty
|
||||
|
||||
---
|
||||
|
||||
#### `GetStoredMusicLibrary(sourceAccount string) (*models.NavigateResponse, error)`
|
||||
|
||||
Browse stored/local music library.
|
||||
|
||||
**Parameters:**
|
||||
- `sourceAccount` (string, required): Device account identifier (format: `deviceID/index`)
|
||||
|
||||
**Returns:**
|
||||
- `*models.NavigateResponse`: Music library root contents
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
library, err := client.GetStoredMusicLibrary("A81B6A536A98/0")
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `sourceAccount` cannot be empty
|
||||
|
||||
---
|
||||
|
||||
### Search Methods
|
||||
|
||||
#### `SearchStation(source, sourceAccount, searchTerm string) (*models.SearchStationResponse, error)`
|
||||
|
||||
Search for stations and content within a music service.
|
||||
|
||||
**Parameters:**
|
||||
- `source` (string, required): Service to search
|
||||
- `sourceAccount` (string, optional): Account identifier
|
||||
- `searchTerm` (string, required): Search query
|
||||
|
||||
**Returns:**
|
||||
- `*models.SearchStationResponse`: Search results categorized by type
|
||||
- `error`: Error if request fails
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
results, err := client.SearchStation("PANDORA", "user123", "jazz")
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `source` cannot be empty
|
||||
- `searchTerm` cannot be empty
|
||||
|
||||
---
|
||||
|
||||
#### `SearchTuneInStations(searchTerm string) (*models.SearchStationResponse, error)`
|
||||
|
||||
Search TuneIn radio stations.
|
||||
|
||||
**Parameters:**
|
||||
- `searchTerm` (string, required): Search query
|
||||
|
||||
**Returns:**
|
||||
- `*models.SearchStationResponse`: TuneIn search results
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
results, err := client.SearchTuneInStations("classical music")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `SearchPandoraStations(sourceAccount, searchTerm string) (*models.SearchStationResponse, error)`
|
||||
|
||||
Search Pandora for artists and stations.
|
||||
|
||||
**Parameters:**
|
||||
- `sourceAccount` (string, required): Pandora account identifier
|
||||
- `searchTerm` (string, required): Artist or genre to search for
|
||||
|
||||
**Returns:**
|
||||
- `*models.SearchStationResponse`: Pandora search results with songs, artists, stations
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
results, err := client.SearchPandoraStations("user123", "Taylor Swift")
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `sourceAccount` cannot be empty
|
||||
|
||||
---
|
||||
|
||||
#### `SearchSpotifyContent(sourceAccount, searchTerm string) (*models.SearchStationResponse, error)`
|
||||
|
||||
Search Spotify for tracks, albums, and playlists.
|
||||
|
||||
**Parameters:**
|
||||
- `sourceAccount` (string, required): Spotify account identifier
|
||||
- `searchTerm` (string, required): Content to search for
|
||||
|
||||
**Returns:**
|
||||
- `*models.SearchStationResponse`: Spotify search results
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
results, err := client.SearchSpotifyContent("user@example.com", "Queen")
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- `sourceAccount` cannot be empty
|
||||
|
||||
---
|
||||
|
||||
### Station Management Methods
|
||||
|
||||
#### `AddStation(source, sourceAccount, token, name string) error`
|
||||
|
||||
Add a station to music service collection and immediately start playing it.
|
||||
|
||||
**Parameters:**
|
||||
- `source` (string, required): Music service identifier
|
||||
- `sourceAccount` (string, optional): Account identifier
|
||||
- `token` (string, required): Station token from search results
|
||||
- `name` (string, required): Display name for the station
|
||||
|
||||
**Returns:**
|
||||
- `error`: Error if operation fails
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
err := client.AddStation("PANDORA", "user123", "R4328162", "Classic Rock Radio")
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Station is immediately selected and starts playing
|
||||
- Station is added to user's collection permanently
|
||||
- Generates `presetsUpdated` WebSocket event if station is stored as preset
|
||||
|
||||
**Validation:**
|
||||
- `source` cannot be empty
|
||||
- `token` cannot be empty
|
||||
- `name` cannot be empty
|
||||
|
||||
---
|
||||
|
||||
#### `RemoveStation(contentItem *models.ContentItem) error`
|
||||
|
||||
Remove a station from music service collection.
|
||||
|
||||
**Parameters:**
|
||||
- `contentItem` (*models.ContentItem, required): Station content item with source and location
|
||||
|
||||
**Returns:**
|
||||
- `error`: Error if operation fails
|
||||
|
||||
**Example:**
|
||||
```go
|
||||
err := client.RemoveStation(stationContentItem)
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
- Station is removed from user's collection
|
||||
- If station is currently playing, playback stops
|
||||
- Generates `nowPlayingUpdated` WebSocket event if playing station was removed
|
||||
|
||||
**Validation:**
|
||||
- `contentItem` cannot be nil
|
||||
- `contentItem.Source` cannot be empty
|
||||
- `contentItem.Location` cannot be empty
|
||||
|
||||
---
|
||||
|
||||
## Models
|
||||
|
||||
### NavigateRequest
|
||||
|
||||
Request structure for `/navigate` endpoint.
|
||||
|
||||
```go
|
||||
type NavigateRequest struct {
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
|
||||
Menu string `xml:"menu,attr,omitempty"`
|
||||
Sort string `xml:"sort,attr,omitempty"`
|
||||
StartItem int `xml:"startItem"`
|
||||
NumItems int `xml:"numItems"`
|
||||
Item *NavigateItem `xml:"item,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Constructors:**
|
||||
- `NewNavigateRequest(source, sourceAccount string, startItem, numItems int)`
|
||||
- `NewNavigateRequestWithMenu(source, sourceAccount, menu, sort string, startItem, numItems int)`
|
||||
- `NewNavigateRequestWithItem(source, sourceAccount string, startItem, numItems int, item *ContentItem)`
|
||||
|
||||
---
|
||||
|
||||
### NavigateResponse
|
||||
|
||||
Response structure from navigation operations.
|
||||
|
||||
```go
|
||||
type NavigateResponse struct {
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
|
||||
TotalItems int `xml:"totalItems"`
|
||||
Items []NavigateItem `xml:"items>item"`
|
||||
}
|
||||
```
|
||||
|
||||
**Helper Methods:**
|
||||
- `GetPlayableItems() []NavigateItem` - Filter items with `Playable="1"`
|
||||
- `GetDirectories() []NavigateItem` - Filter directory items (`type="dir"`)
|
||||
- `GetTracks() []NavigateItem` - Filter track items (`type="track"`)
|
||||
- `GetStations() []NavigateItem` - Filter station items (`type="stationurl"`)
|
||||
- `IsEmpty() bool` - Check if response has no items
|
||||
|
||||
---
|
||||
|
||||
### NavigateItem
|
||||
|
||||
Individual item within navigation response.
|
||||
|
||||
```go
|
||||
type NavigateItem struct {
|
||||
Playable int `xml:"Playable,attr,omitempty"`
|
||||
Name string `xml:"name"`
|
||||
Type string `xml:"type"`
|
||||
ContentItem *ContentItem `xml:"ContentItem,omitempty"`
|
||||
MediaItemContainer *MediaItemContainer `xml:"mediaItemContainer,omitempty"`
|
||||
ArtistName string `xml:"artistName,omitempty"`
|
||||
AlbumName string `xml:"albumName,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Helper Methods:**
|
||||
- `GetDisplayName() string` - Get formatted display name
|
||||
- `IsPlayable() bool` - Check if `Playable="1"`
|
||||
- `IsDirectory() bool` - Check if `type="dir"`
|
||||
- `IsTrack() bool` - Check if `type="track"`
|
||||
- `IsStation() bool` - Check if `type="stationurl"`
|
||||
- `GetContentItem() *ContentItem` - Get associated content item
|
||||
- `GetArtwork() string` - Get artwork URL from content item
|
||||
|
||||
**Common Type Values:**
|
||||
- `"dir"` - Directory/container
|
||||
- `"track"` - Music track
|
||||
- `"stationurl"` - Radio station
|
||||
- `"playlist"` - Playlist
|
||||
- `"album"` - Album
|
||||
|
||||
---
|
||||
|
||||
### SearchStationRequest
|
||||
|
||||
Request structure for station search.
|
||||
|
||||
```go
|
||||
type SearchStationRequest struct {
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
|
||||
SearchTerm string `xml:",chardata"`
|
||||
}
|
||||
```
|
||||
|
||||
**Constructor:**
|
||||
- `NewSearchStationRequest(source, sourceAccount, searchTerm string)`
|
||||
|
||||
---
|
||||
|
||||
### SearchStationResponse
|
||||
|
||||
Response structure from search operations.
|
||||
|
||||
```go
|
||||
type SearchStationResponse struct {
|
||||
DeviceID string `xml:"deviceID,attr"`
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
|
||||
Songs []SearchResult `xml:"songs>searchResult"`
|
||||
Artists []SearchResult `xml:"artists>searchResult"`
|
||||
Stations []SearchResult `xml:"stations>searchResult"`
|
||||
}
|
||||
```
|
||||
|
||||
**Helper Methods:**
|
||||
- `GetSongs() []SearchResult` - Get song results
|
||||
- `GetArtists() []SearchResult` - Get artist results
|
||||
- `GetStations() []SearchResult` - Get station results
|
||||
- `GetAllResults() []SearchResult` - Get all results combined
|
||||
- `GetResultCount() int` - Count total results
|
||||
- `HasResults() bool` - Check if any results found
|
||||
- `IsEmpty() bool` - Check if no results
|
||||
|
||||
---
|
||||
|
||||
### SearchResult
|
||||
|
||||
Individual search result item.
|
||||
|
||||
```go
|
||||
type SearchResult struct {
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
|
||||
Token string `xml:"token,attr"`
|
||||
Name string `xml:"name"`
|
||||
Artist string `xml:"artist,omitempty"`
|
||||
Album string `xml:"album,omitempty"`
|
||||
Logo string `xml:"logo,omitempty"`
|
||||
Description string `xml:"description,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Helper Methods:**
|
||||
- `IsSong() bool` - Check if result is a song (has `Artist` field)
|
||||
- `IsArtist() bool` - Check if result is an artist (no `Artist` or `Description`)
|
||||
- `IsStation() bool` - Check if result is a station (has `Description`)
|
||||
- `GetDisplayName() string` - Get formatted name
|
||||
- `GetFullTitle() string` - Get name with artist for songs
|
||||
- `GetArtworkURL() string` - Get logo/artwork URL
|
||||
|
||||
**Token Usage:**
|
||||
The `Token` field is used with `AddStation()` to add the result to your collection.
|
||||
|
||||
---
|
||||
|
||||
### AddStationRequest
|
||||
|
||||
Request structure for adding stations.
|
||||
|
||||
```go
|
||||
type AddStationRequest struct {
|
||||
Source string `xml:"source,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr,omitempty"`
|
||||
Token string `xml:"token,attr"`
|
||||
Name string `xml:"name"`
|
||||
}
|
||||
```
|
||||
|
||||
**Constructor:**
|
||||
- `NewAddStationRequest(source, sourceAccount, token, name string)`
|
||||
|
||||
---
|
||||
|
||||
### StationResponse
|
||||
|
||||
Response structure from station management operations.
|
||||
|
||||
```go
|
||||
type StationResponse struct {
|
||||
Status string `xml:",chardata"`
|
||||
}
|
||||
```
|
||||
|
||||
**Common Values:**
|
||||
- `"/addStation"` - Station added successfully
|
||||
- `"/removeStation"` - Station removed successfully
|
||||
|
||||
---
|
||||
|
||||
## HTTP Endpoints
|
||||
|
||||
### POST /navigate
|
||||
|
||||
Browse content within a source.
|
||||
|
||||
**Request Body:**
|
||||
```xml
|
||||
<navigate source="TUNEIN" sourceAccount="">
|
||||
<startItem>1</startItem>
|
||||
<numItems>25</numItems>
|
||||
</navigate>
|
||||
```
|
||||
|
||||
**Response Body:**
|
||||
```xml
|
||||
<navigateResponse source="TUNEIN">
|
||||
<totalItems>5</totalItems>
|
||||
<items>
|
||||
<item Playable="1">
|
||||
<name>Station Name</name>
|
||||
<type>stationurl</type>
|
||||
<ContentItem source="TUNEIN" location="/v1/playback/station/s12345" isPresetable="true">
|
||||
<itemName>Station Name</itemName>
|
||||
</ContentItem>
|
||||
</item>
|
||||
</items>
|
||||
</navigateResponse>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /searchStation
|
||||
|
||||
Search for stations and content.
|
||||
|
||||
**Request Body:**
|
||||
```xml
|
||||
<search source="PANDORA" sourceAccount="user123">Taylor Swift</search>
|
||||
```
|
||||
|
||||
**Response Body:**
|
||||
```xml
|
||||
<results deviceID="A81B6A536A98" source="PANDORA" sourceAccount="user123">
|
||||
<songs>
|
||||
<searchResult source="PANDORA" sourceAccount="user123" token="S123">
|
||||
<name>Love Story</name>
|
||||
<artist>Taylor Swift</artist>
|
||||
<logo>http://example.com/artwork.jpg</logo>
|
||||
</searchResult>
|
||||
</songs>
|
||||
<artists>
|
||||
<searchResult source="PANDORA" sourceAccount="user123" token="R456">
|
||||
<name>Taylor Swift</name>
|
||||
<logo>http://example.com/artist.jpg</logo>
|
||||
</searchResult>
|
||||
</artists>
|
||||
</results>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /addStation
|
||||
|
||||
Add a station to collection and start playing.
|
||||
|
||||
**Request Body:**
|
||||
```xml
|
||||
<addStation source="PANDORA" sourceAccount="user123" token="R456">
|
||||
<name>Taylor Swift Radio</name>
|
||||
</addStation>
|
||||
```
|
||||
|
||||
**Response Body:**
|
||||
```xml
|
||||
<status>/addStation</status>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /removeStation
|
||||
|
||||
Remove a station from collection.
|
||||
|
||||
**Request Body:**
|
||||
```xml
|
||||
<ContentItem source="PANDORA" location="126740707481236361" sourceAccount="user123" isPresetable="true">
|
||||
<itemName>Taylor Swift Radio</itemName>
|
||||
</ContentItem>
|
||||
```
|
||||
|
||||
**Response Body:**
|
||||
```xml
|
||||
<status>/removeStation</status>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## XML Schemas
|
||||
|
||||
### Navigate Request Schema
|
||||
|
||||
```xml
|
||||
<xs:element name="navigate">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="startItem" type="xs:int"/>
|
||||
<xs:element name="numItems" type="xs:int"/>
|
||||
<xs:element name="item" minOccurs="0">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="name" type="xs:string"/>
|
||||
<xs:element name="type" type="xs:string"/>
|
||||
<xs:element name="ContentItem" type="ContentItemType"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="Playable" type="xs:int"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="source" type="xs:string" use="required"/>
|
||||
<xs:attribute name="sourceAccount" type="xs:string"/>
|
||||
<xs:attribute name="menu" type="xs:string"/>
|
||||
<xs:attribute name="sort" type="xs:string"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
```
|
||||
|
||||
### Search Request Schema
|
||||
|
||||
```xml
|
||||
<xs:element name="search">
|
||||
<xs:complexType>
|
||||
<xs:simpleContent>
|
||||
<xs:extension base="xs:string">
|
||||
<xs:attribute name="source" type="xs:string" use="required"/>
|
||||
<xs:attribute name="sourceAccount" type="xs:string"/>
|
||||
</xs:extension>
|
||||
</xs:simpleContent>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
```
|
||||
|
||||
### ContentItem Type Schema
|
||||
|
||||
```xml
|
||||
<xs:complexType name="ContentItemType">
|
||||
<xs:sequence>
|
||||
<xs:element name="itemName" type="xs:string" minOccurs="0"/>
|
||||
<xs:element name="containerArt" type="xs:string" minOccurs="0"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="source" type="xs:string" use="required"/>
|
||||
<xs:attribute name="type" type="xs:string"/>
|
||||
<xs:attribute name="location" type="xs:string"/>
|
||||
<xs:attribute name="sourceAccount" type="xs:string"/>
|
||||
<xs:attribute name="isPresetable" type="xs:boolean"/>
|
||||
</xs:complexType>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Codes
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
| Status | Meaning | Description |
|
||||
|--------|---------|-------------|
|
||||
| 200 | OK | Request successful |
|
||||
| 400 | Bad Request | Invalid parameters or XML |
|
||||
| 404 | Not Found | Endpoint or content not found |
|
||||
| 500 | Internal Server Error | Device error |
|
||||
|
||||
### Common Error Responses
|
||||
|
||||
**Invalid Source:**
|
||||
```xml
|
||||
<error>
|
||||
<code>INVALID_SOURCE</code>
|
||||
<message>Source 'INVALID' is not available</message>
|
||||
</error>
|
||||
```
|
||||
|
||||
**Authentication Required:**
|
||||
```xml
|
||||
<error>
|
||||
<code>AUTH_REQUIRED</code>
|
||||
<message>Source account required for this service</message>
|
||||
</error>
|
||||
```
|
||||
|
||||
**Service Unavailable:**
|
||||
```xml
|
||||
<error>
|
||||
<code>SERVICE_UNAVAILABLE</code>
|
||||
<message>PANDORA service is not configured</message>
|
||||
</error>
|
||||
```
|
||||
|
||||
### Client-Side Validation Errors
|
||||
|
||||
The Go client performs validation before sending requests:
|
||||
|
||||
| Error Message | Cause | Solution |
|
||||
|---------------|-------|----------|
|
||||
| `"source cannot be empty"` | Empty source parameter | Provide valid source |
|
||||
| `"search term cannot be empty"` | Empty search query | Provide search term |
|
||||
| `"startItem must be >= 1"` | Invalid start position | Use 1-based indexing |
|
||||
| `"numItems must be >= 1"` | Invalid page size | Use positive number |
|
||||
| `"content item cannot be nil"` | Nil ContentItem | Provide valid ContentItem |
|
||||
| `"container item cannot be nil"` | Nil container for NavigateContainer | Provide valid container |
|
||||
| `"Pandora source account cannot be empty"` | Missing Pandora account | Configure Pandora account |
|
||||
| `"token cannot be empty"` | Missing station token | Use token from search results |
|
||||
| `"station name cannot be empty"` | Missing station name | Provide station name |
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
Navigation and station operations generate WebSocket events:
|
||||
|
||||
### presetsUpdated
|
||||
|
||||
Generated when stations are added/removed that affect presets.
|
||||
|
||||
```xml
|
||||
<presetsUpdated deviceID="A81B6A536A98">
|
||||
<presets>
|
||||
<!-- Updated preset list -->
|
||||
</presets>
|
||||
</presetsUpdated>
|
||||
```
|
||||
|
||||
### nowPlayingUpdated
|
||||
|
||||
Generated when station operations affect current playback.
|
||||
|
||||
```xml
|
||||
<nowPlayingUpdated deviceID="A81B6A536A98">
|
||||
<nowPlaying source="PANDORA">
|
||||
<ContentItem source="PANDORA" location="R456" sourceAccount="user123" isPresetable="true">
|
||||
<itemName>Taylor Swift Radio</itemName>
|
||||
</ContentItem>
|
||||
<track>Love Story</track>
|
||||
<artist>Taylor Swift</artist>
|
||||
<playStatus>PLAY_STATE</playStatus>
|
||||
</nowPlaying>
|
||||
</nowPlayingUpdated>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Parameter Validation
|
||||
|
||||
Always validate parameters before API calls:
|
||||
|
||||
```go
|
||||
func validateNavigateParams(source string, startItem, numItems int) error {
|
||||
if source == "" {
|
||||
return fmt.Errorf("source cannot be empty")
|
||||
}
|
||||
if startItem < 1 {
|
||||
return fmt.Errorf("startItem must be >= 1")
|
||||
}
|
||||
if numItems < 1 {
|
||||
return fmt.Errorf("numItems must be >= 1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Handle both network and API errors:
|
||||
|
||||
```go
|
||||
response, err := client.Navigate("TUNEIN", "", 1, 25)
|
||||
if err != nil {
|
||||
// Check if it's a known API error
|
||||
if strings.Contains(err.Error(), "not available") {
|
||||
log.Printf("TuneIn not configured on device")
|
||||
return
|
||||
}
|
||||
return fmt.Errorf("navigation failed: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
Use appropriate page sizes for different contexts:
|
||||
|
||||
```go
|
||||
// Small pages for interactive browsing
|
||||
response, err := client.Navigate("TUNEIN", "", 1, 25)
|
||||
|
||||
// Larger pages for bulk processing
|
||||
response, err := client.Navigate("STORED_MUSIC", "device/0", 1, 100)
|
||||
```
|
||||
|
||||
### Resource Management
|
||||
|
||||
Cache frequently accessed data:
|
||||
|
||||
```go
|
||||
type CachedClient struct {
|
||||
client *client.Client
|
||||
sources *models.Sources
|
||||
sourcesTime time.Time
|
||||
}
|
||||
|
||||
func (c *CachedClient) GetSources() (*models.Sources, error) {
|
||||
if c.sources == nil || time.Since(c.sourcesTime) > 5*time.Minute {
|
||||
var err error
|
||||
c.sources, err = c.client.GetSources()
|
||||
c.sourcesTime = time.Now()
|
||||
return c.sources, err
|
||||
}
|
||||
return c.sources, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*For complete usage examples and workflows, see [NAVIGATION-GUIDE.md](NAVIGATION-GUIDE.md).*
|
||||
+10
-3
@@ -4,9 +4,9 @@
|
||||
|
||||
This document contains important development guidelines for working on the Bose SoundTouch project. Please also read the following documentation:
|
||||
|
||||
- **[PLAN.md](PLAN.md)** - Project planning and roadmap
|
||||
- **[PLAN.md](archive/PLAN.md)** - Project planning and roadmap
|
||||
- **[PROJECT-PATTERNS.md](PROJECT-PATTERNS.md)** - Project structure and design patterns
|
||||
- **[API-Endpoints-Overview.md](API-Endpoints-Overview.md)** - API endpoints overview
|
||||
- **[API-ENDPOINTS.md](reference/API-ENDPOINTS.md)** - API endpoints overview
|
||||
- **[SoundTouch Web API.pdf](2025.12.18%20SoundTouch%20Web%20API.pdf)** - Official API documentation
|
||||
|
||||
## Development Guidelines
|
||||
@@ -80,6 +80,14 @@ When creating test data for API endpoints, prefer real device responses over hyp
|
||||
- **Coverage**: Use multiple real devices to cover different response variations
|
||||
- **Non-responsive endpoints**: Some endpoints like `/trackInfo` may not respond or exist on all devices
|
||||
|
||||
### 9. File Operations Safety
|
||||
|
||||
- **Never delete files** - use move/rename instead when possible
|
||||
- **Ask before destructive operations** - especially for config files (.env, *.config, etc.)
|
||||
- **Prefer non-destructive operations** - copy, move, rename over delete
|
||||
- **Respect user data** - treat all user files as potentially containing sensitive data
|
||||
- **Configuration files are sacred** - .env, config files may contain secrets and personal settings
|
||||
|
||||
## Additional Notes
|
||||
|
||||
- **Language: English** for code, commits, labels, and text in code
|
||||
@@ -87,4 +95,3 @@ When creating test data for API endpoints, prefer real device responses over hyp
|
||||
- **Documentation**: Completely in English for international accessibility
|
||||
- Conduct regular code reviews
|
||||
- Consider performance from the beginning
|
||||
|
||||
|
||||
@@ -1,602 +0,0 @@
|
||||
# SoundTouch CLI Reference
|
||||
|
||||
**Complete command reference for the soundtouch-cli tool**
|
||||
|
||||
This document provides comprehensive documentation for all available commands and options in the `soundtouch-cli` tool.
|
||||
|
||||
## Overview
|
||||
|
||||
The SoundTouch CLI uses a hierarchical command structure with subcommands for different operations:
|
||||
|
||||
```bash
|
||||
soundtouch-cli [global-flags] <command> [command-flags] [subcommand] [subcommand-flags]
|
||||
```
|
||||
|
||||
## Global Flags
|
||||
|
||||
These flags can be used with any command:
|
||||
|
||||
| Flag | Short | Description | Default |
|
||||
|------|-------|-------------|---------|
|
||||
| `--host` | `-h` | Device IP address or hostname | Required for most commands |
|
||||
| `--port` | `-p` | Device port number | `8090` |
|
||||
| `--timeout` | `-t` | Request timeout duration | `10s` |
|
||||
| `--help` | | Show command help | |
|
||||
| `--version` | `-v` | Show CLI version | |
|
||||
|
||||
## Commands
|
||||
|
||||
### Discovery
|
||||
|
||||
Discover SoundTouch devices on the network.
|
||||
|
||||
#### `discover devices`
|
||||
|
||||
Discover and list all SoundTouch devices.
|
||||
|
||||
```bash
|
||||
soundtouch-cli discover devices [flags]
|
||||
```
|
||||
|
||||
**Flags:**
|
||||
- `--all`, `-a`: Show detailed information for all devices
|
||||
- `--timeout`: Discovery timeout (default: 10s)
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Basic discovery
|
||||
soundtouch-cli discover devices
|
||||
|
||||
# Show detailed info for all discovered devices
|
||||
soundtouch-cli discover devices --all
|
||||
|
||||
# Discovery with custom timeout
|
||||
soundtouch-cli discover devices --timeout 15s
|
||||
```
|
||||
|
||||
### Device Information
|
||||
|
||||
Get information about your SoundTouch device.
|
||||
|
||||
#### `info`
|
||||
|
||||
Get basic device information.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> info
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
soundtouch-cli --host 192.168.1.10 info
|
||||
```
|
||||
|
||||
#### `name get|set`
|
||||
|
||||
Get or set the device name.
|
||||
|
||||
```bash
|
||||
# Get current name
|
||||
soundtouch-cli --host <device> name get
|
||||
|
||||
# Set new name
|
||||
soundtouch-cli --host <device> name set --value "My SoundTouch"
|
||||
```
|
||||
|
||||
#### `capabilities`
|
||||
|
||||
Get device capabilities and features.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> capabilities
|
||||
```
|
||||
|
||||
#### `presets`
|
||||
|
||||
Get configured presets.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> presets
|
||||
```
|
||||
|
||||
### Playback Control
|
||||
|
||||
Control music playback on your device.
|
||||
|
||||
#### `play <subcommand>`
|
||||
|
||||
Playback control commands.
|
||||
|
||||
```bash
|
||||
# Get current playback status
|
||||
soundtouch-cli --host <device> play now
|
||||
|
||||
# Start playback
|
||||
soundtouch-cli --host <device> play start
|
||||
|
||||
# Pause playback
|
||||
soundtouch-cli --host <device> play pause
|
||||
|
||||
# Stop playback
|
||||
soundtouch-cli --host <device> play stop
|
||||
|
||||
# Next track
|
||||
soundtouch-cli --host <device> play next
|
||||
|
||||
# Previous track
|
||||
soundtouch-cli --host <device> play prev
|
||||
```
|
||||
|
||||
#### `preset`
|
||||
|
||||
Select a preset by number.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> preset --preset <1-6>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Select preset 1
|
||||
soundtouch-cli --host 192.168.1.10 preset --preset 1
|
||||
|
||||
# Select preset 6
|
||||
soundtouch-cli --host 192.168.1.10 preset --preset 6
|
||||
```
|
||||
|
||||
#### `track`
|
||||
|
||||
Get current track information.
|
||||
|
||||
```bash
|
||||
soundtouch-cli --host <device> track
|
||||
```
|
||||
|
||||
### Key Commands
|
||||
|
||||
Send key commands to the device (simulates remote control).
|
||||
|
||||
#### `key <subcommand>`
|
||||
|
||||
Send various key commands.
|
||||
|
||||
```bash
|
||||
# Send generic key command
|
||||
soundtouch-cli --host <device> key send --key <KEY_NAME>
|
||||
|
||||
# Specific key commands
|
||||
soundtouch-cli --host <device> key power
|
||||
soundtouch-cli --host <device> key mute
|
||||
soundtouch-cli --host <device> key thumbs-up
|
||||
soundtouch-cli --host <device> key thumbs-down
|
||||
soundtouch-cli --host <device> key volume-up
|
||||
soundtouch-cli --host <device> key volume-down
|
||||
```
|
||||
|
||||
**Available Key Names:**
|
||||
- `PLAY`, `PAUSE`, `STOP`
|
||||
- `POWER`, `MUTE`
|
||||
- `VOLUME_UP`, `VOLUME_DOWN`
|
||||
- `PRESET_1` through `PRESET_6`
|
||||
- `NEXT_TRACK`, `PREV_TRACK`
|
||||
- `THUMBS_UP`, `THUMBS_DOWN`
|
||||
- `SHUFFLE_ON`, `SHUFFLE_OFF`
|
||||
- `REPEAT_ON`, `REPEAT_OFF`
|
||||
|
||||
### Volume Control
|
||||
|
||||
Manage device volume.
|
||||
|
||||
#### `volume <subcommand>`
|
||||
|
||||
Volume control commands.
|
||||
|
||||
```bash
|
||||
# Get current volume
|
||||
soundtouch-cli --host <device> volume get
|
||||
|
||||
# Set specific volume level (0-100)
|
||||
soundtouch-cli --host <device> volume set --level <0-100>
|
||||
|
||||
# Increase volume
|
||||
soundtouch-cli --host <device> volume up [--amount <1-10>]
|
||||
|
||||
# Decrease volume
|
||||
soundtouch-cli --host <device> volume down [--amount <1-10>]
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get volume
|
||||
soundtouch-cli --host 192.168.1.10 volume get
|
||||
|
||||
# Set volume to 50
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 50
|
||||
|
||||
# Increase volume by 5
|
||||
soundtouch-cli --host 192.168.1.10 volume up --amount 5
|
||||
|
||||
# Decrease volume by 3 (default amount is 2)
|
||||
soundtouch-cli --host 192.168.1.10 volume down --amount 3
|
||||
```
|
||||
|
||||
### Audio Sources
|
||||
|
||||
Manage audio input sources.
|
||||
|
||||
#### `source <subcommand>`
|
||||
|
||||
Audio source commands.
|
||||
|
||||
```bash
|
||||
# List available sources
|
||||
soundtouch-cli --host <device> source list
|
||||
|
||||
# Select specific source
|
||||
soundtouch-cli --host <device> source select --source <SOURCE> [--account <ACCOUNT>]
|
||||
|
||||
# Quick source selection
|
||||
soundtouch-cli --host <device> source spotify
|
||||
soundtouch-cli --host <device> source bluetooth
|
||||
soundtouch-cli --host <device> source aux
|
||||
```
|
||||
|
||||
**Source Names:**
|
||||
- `SPOTIFY` - Spotify streaming
|
||||
- `BLUETOOTH` - Bluetooth input
|
||||
- `AUX` - AUX input
|
||||
- `AIRPLAY` - AirPlay
|
||||
- `STORED_MUSIC` - Local music library
|
||||
- `INTERNET_RADIO` - Internet radio
|
||||
- `PRODUCT` - Product-specific sources
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# List all sources
|
||||
soundtouch-cli --host 192.168.1.10 source list
|
||||
|
||||
# Select Spotify
|
||||
soundtouch-cli --host 192.168.1.10 source spotify
|
||||
|
||||
# Select Spotify with specific account
|
||||
soundtouch-cli --host 192.168.1.10 source select --source SPOTIFY --account user@example.com
|
||||
|
||||
# Select Bluetooth
|
||||
soundtouch-cli --host 192.168.1.10 source bluetooth
|
||||
```
|
||||
|
||||
### Bass Control
|
||||
|
||||
Adjust bass levels (equalizer).
|
||||
|
||||
#### `bass <subcommand>`
|
||||
|
||||
Bass control commands.
|
||||
|
||||
```bash
|
||||
# Get current bass level
|
||||
soundtouch-cli --host <device> bass get
|
||||
|
||||
# Set bass level (-9 to 9)
|
||||
soundtouch-cli --host <device> bass set --level <-9 to 9>
|
||||
|
||||
# Increase bass
|
||||
soundtouch-cli --host <device> bass up [--amount <1-5>]
|
||||
|
||||
# Decrease bass
|
||||
soundtouch-cli --host <device> bass down [--amount <1-5>]
|
||||
|
||||
# Get bass capabilities
|
||||
soundtouch-cli --host <device> bass capabilities
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get current bass
|
||||
soundtouch-cli --host 192.168.1.10 bass get
|
||||
|
||||
# Set bass to +3
|
||||
soundtouch-cli --host 192.168.1.10 bass set --level 3
|
||||
|
||||
# Increase bass by 2
|
||||
soundtouch-cli --host 192.168.1.10 bass up --amount 2
|
||||
|
||||
# Decrease bass by 1 (default)
|
||||
soundtouch-cli --host 192.168.1.10 bass down
|
||||
```
|
||||
|
||||
### Balance Control
|
||||
|
||||
Adjust left/right balance.
|
||||
|
||||
#### `balance <subcommand>`
|
||||
|
||||
Balance control commands.
|
||||
|
||||
```bash
|
||||
# Get current balance
|
||||
soundtouch-cli --host <device> balance get
|
||||
|
||||
# Set balance (-50 to 50, negative=left, positive=right)
|
||||
soundtouch-cli --host <device> balance set --level <-50 to 50>
|
||||
|
||||
# Shift balance left
|
||||
soundtouch-cli --host <device> balance left [--amount <1-10>]
|
||||
|
||||
# Shift balance right
|
||||
soundtouch-cli --host <device> balance right [--amount <1-10>]
|
||||
|
||||
# Center balance
|
||||
soundtouch-cli --host <device> balance center
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get balance
|
||||
soundtouch-cli --host 192.168.1.10 balance get
|
||||
|
||||
# Set balance 10 units to the right
|
||||
soundtouch-cli --host 192.168.1.10 balance set --level 10
|
||||
|
||||
# Shift left by 5 units (default)
|
||||
soundtouch-cli --host 192.168.1.10 balance left
|
||||
|
||||
# Center the balance
|
||||
soundtouch-cli --host 192.168.1.10 balance center
|
||||
```
|
||||
|
||||
### Clock and Time
|
||||
|
||||
Manage device clock settings.
|
||||
|
||||
#### `clock <subcommand>`
|
||||
|
||||
Clock control commands.
|
||||
|
||||
```bash
|
||||
# Get current time
|
||||
soundtouch-cli --host <device> clock get
|
||||
|
||||
# Set time manually (HH:MM format)
|
||||
soundtouch-cli --host <device> clock set --time "14:30"
|
||||
|
||||
# Set to current system time
|
||||
soundtouch-cli --host <device> clock now
|
||||
|
||||
# Display settings
|
||||
soundtouch-cli --host <device> clock display get
|
||||
soundtouch-cli --host <device> clock display enable
|
||||
soundtouch-cli --host <device> clock display disable
|
||||
soundtouch-cli --host <device> clock display brightness --brightness <low|medium|high|off>
|
||||
soundtouch-cli --host <device> clock display format --format <12|24>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get current time
|
||||
soundtouch-cli --host 192.168.1.10 clock get
|
||||
|
||||
# Set time to 2:30 PM
|
||||
soundtouch-cli --host 192.168.1.10 clock set --time "14:30"
|
||||
|
||||
# Sync with system time
|
||||
soundtouch-cli --host 192.168.1.10 clock now
|
||||
|
||||
# Enable clock display
|
||||
soundtouch-cli --host 192.168.1.10 clock display enable
|
||||
|
||||
# Set 24-hour format
|
||||
soundtouch-cli --host 192.168.1.10 clock display format --format 24
|
||||
|
||||
# Set high brightness
|
||||
soundtouch-cli --host 192.168.1.10 clock display brightness --brightness high
|
||||
```
|
||||
|
||||
### Network Information
|
||||
|
||||
Get network and connectivity information.
|
||||
|
||||
#### `network <subcommand>`
|
||||
|
||||
Network information commands.
|
||||
|
||||
```bash
|
||||
# Get network information
|
||||
soundtouch-cli --host <device> network info
|
||||
|
||||
# Ping the device
|
||||
soundtouch-cli --host <device> network ping
|
||||
|
||||
# Get device base URL
|
||||
soundtouch-cli --host <device> network url
|
||||
```
|
||||
|
||||
### Zone Management
|
||||
|
||||
Manage multi-room zones (multiple speakers playing together).
|
||||
|
||||
#### `zone <subcommand>`
|
||||
|
||||
Zone management commands.
|
||||
|
||||
```bash
|
||||
# Get current zone configuration
|
||||
soundtouch-cli --host <device> zone get
|
||||
|
||||
# Get zone status
|
||||
soundtouch-cli --host <device> zone status
|
||||
|
||||
# List zone members
|
||||
soundtouch-cli --host <device> zone members
|
||||
|
||||
# Create new zone
|
||||
soundtouch-cli --host <device> zone create --members <ip1,ip2,ip3>
|
||||
|
||||
# Add device to zone
|
||||
soundtouch-cli --host <device> zone add --member <ip>
|
||||
|
||||
# Remove device from zone
|
||||
soundtouch-cli --host <device> zone remove --member <ip>
|
||||
|
||||
# Dissolve current zone
|
||||
soundtouch-cli --host <device> zone dissolve
|
||||
|
||||
# Set zone configuration
|
||||
soundtouch-cli --host <device> zone set --master <ip> --members <ip1,ip2>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Get current zone info
|
||||
soundtouch-cli --host 192.168.1.10 zone get
|
||||
|
||||
# Create zone with three speakers
|
||||
soundtouch-cli --host 192.168.1.10 zone create --members 192.168.1.11,192.168.1.12
|
||||
|
||||
# Add speaker to existing zone
|
||||
soundtouch-cli --host 192.168.1.10 zone add --member 192.168.1.13
|
||||
|
||||
# Remove speaker from zone
|
||||
soundtouch-cli --host 192.168.1.10 zone remove --member 192.168.1.12
|
||||
|
||||
# Dissolve the zone (make all speakers independent)
|
||||
soundtouch-cli --host 192.168.1.10 zone dissolve
|
||||
```
|
||||
|
||||
## Common Usage Patterns
|
||||
|
||||
### Quick Device Setup
|
||||
|
||||
```bash
|
||||
# Discover devices
|
||||
soundtouch-cli discover devices
|
||||
|
||||
# Get device info
|
||||
soundtouch-cli --host 192.168.1.10 info
|
||||
|
||||
# Set comfortable volume and start playing
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 30
|
||||
soundtouch-cli --host 192.168.1.10 source spotify
|
||||
soundtouch-cli --host 192.168.1.10 play start
|
||||
```
|
||||
|
||||
### Daily Usage
|
||||
|
||||
```bash
|
||||
# Morning routine
|
||||
soundtouch-cli --host 192.168.1.10 preset --preset 1 # Morning playlist
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 25
|
||||
|
||||
# Pause for a call
|
||||
soundtouch-cli --host 192.168.1.10 play pause
|
||||
|
||||
# Resume
|
||||
soundtouch-cli --host 192.168.1.10 play start
|
||||
|
||||
# Evening routine
|
||||
soundtouch-cli --host 192.168.1.10 preset --preset 3 # Evening playlist
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 15
|
||||
```
|
||||
|
||||
### Multi-room Setup
|
||||
|
||||
```bash
|
||||
# Create a zone with living room as master
|
||||
soundtouch-cli --host 192.168.1.10 zone create --members 192.168.1.11,192.168.1.12
|
||||
|
||||
# Control the whole zone from master
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 40
|
||||
soundtouch-cli --host 192.168.1.10 source spotify
|
||||
soundtouch-cli --host 192.168.1.10 preset --preset 2
|
||||
|
||||
# Later, dissolve the zone
|
||||
soundtouch-cli --host 192.168.1.10 zone dissolve
|
||||
```
|
||||
|
||||
### Audio Tuning
|
||||
|
||||
```bash
|
||||
# Get current audio settings
|
||||
soundtouch-cli --host 192.168.1.10 volume get
|
||||
soundtouch-cli --host 192.168.1.10 bass get
|
||||
soundtouch-cli --host 192.168.1.10 balance get
|
||||
|
||||
# Adjust for better sound
|
||||
soundtouch-cli --host 192.168.1.10 bass set --level 2 # Slight bass boost
|
||||
soundtouch-cli --host 192.168.1.10 balance set --level -5 # Slightly left
|
||||
soundtouch-cli --host 192.168.1.10 volume set --level 35 # Good listening level
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The CLI provides clear error messages for common issues:
|
||||
|
||||
### Device Not Found
|
||||
```
|
||||
Error: Failed to connect to device: connection refused
|
||||
```
|
||||
**Solutions:**
|
||||
- Check IP address is correct
|
||||
- Ensure device is powered on
|
||||
- Verify network connectivity with `soundtouch-cli --host <device> network ping`
|
||||
|
||||
### Invalid Commands
|
||||
```
|
||||
Error: unknown command "volumee" for "soundtouch-cli"
|
||||
```
|
||||
**Solution:** Check command spelling and structure using `--help`
|
||||
|
||||
### Missing Required Flags
|
||||
```
|
||||
Error: required flag "host" not set
|
||||
```
|
||||
**Solution:** Provide required flags: `--host <device>`
|
||||
|
||||
## Getting Help
|
||||
|
||||
```bash
|
||||
# General help
|
||||
soundtouch-cli --help
|
||||
|
||||
# Command-specific help
|
||||
soundtouch-cli volume --help
|
||||
soundtouch-cli zone --help
|
||||
|
||||
# Subcommand help
|
||||
soundtouch-cli volume set --help
|
||||
soundtouch-cli zone create --help
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can set default values using environment variables:
|
||||
|
||||
```bash
|
||||
export SOUNDTOUCH_HOST=192.168.1.10
|
||||
export SOUNDTOUCH_PORT=8090
|
||||
export SOUNDTOUCH_TIMEOUT=15s
|
||||
|
||||
# Now you can omit these flags
|
||||
soundtouch-cli info
|
||||
soundtouch-cli volume get
|
||||
```
|
||||
|
||||
### Configuration File
|
||||
|
||||
Create `~/.soundtouch.env`:
|
||||
|
||||
```
|
||||
SOUNDTOUCH_HOST=192.168.1.10
|
||||
SOUNDTOUCH_PORT=8090
|
||||
SOUNDTOUCH_TIMEOUT=15s
|
||||
SOUNDTOUCH_DISCOVERY_TIMEOUT=10s
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [Getting Started Guide](GETTING-STARTED.md) - Basic setup and usage
|
||||
- [WebSocket Events](websocket-events.md) - Real-time monitoring
|
||||
- [Zone Management](zone-management.md) - Multi-room setup
|
||||
- [API Endpoints](API-Endpoints-Overview.md) - Complete API reference
|
||||
@@ -0,0 +1,229 @@
|
||||
# Content Selection Implementation Summary
|
||||
|
||||
This document summarizes the implementation of advanced content selection features for the Bose SoundTouch Go client, including full support for the LOCAL_INTERNET_RADIO streamUrl format and LOCAL_MUSIC/STORED_MUSIC content selection.
|
||||
|
||||
## ✅ Implementation Status: COMPLETE
|
||||
|
||||
All content selection features from the [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) are now fully implemented with comprehensive API methods, CLI commands, tests, and documentation.
|
||||
|
||||
## 🎯 Features Implemented
|
||||
|
||||
### 1. Core API Methods
|
||||
|
||||
#### `SelectContentItem(contentItem *models.ContentItem) error`
|
||||
- **Purpose**: Generic method for selecting any content using a ContentItem directly
|
||||
- **Use Case**: Maximum flexibility for complex content selection scenarios
|
||||
- **Validation**: Ensures ContentItem is not nil and has a valid source
|
||||
|
||||
#### `SelectLocalInternetRadio(location, sourceAccount, itemName, containerArt string) error`
|
||||
- **Purpose**: Select LOCAL_INTERNET_RADIO content with streamUrl format support
|
||||
- **Features**:
|
||||
- Direct stream URLs (e.g., `https://stream.example.com/radio`)
|
||||
- streamUrl proxy format (e.g., `http://contentapi.gmuth.de/station.php?name=Station&streamUrl=ActualStream`)
|
||||
- Automatic defaults for missing parameters
|
||||
- **Use Cases**: Internet radio streams, proxy-based radio services
|
||||
|
||||
#### `SelectLocalInternetRadio(location, ...)` via `soundtouch-service`
|
||||
- **Purpose**: Select custom radio stream via local `soundtouch-service` proxy
|
||||
- **Features**:
|
||||
- Flexible stream URL encoding (Base64 or URL-escaped)
|
||||
- Dynamic generation of Bose-compatible playback JSON
|
||||
- Seamless integration with existing `LOCAL_INTERNET_RADIO` source
|
||||
- **Use Case**: Playing any internet radio URL without external proxy dependencies
|
||||
|
||||
#### `SelectLocalMusic(location, sourceAccount, itemName, containerArt string) error`
|
||||
- **Purpose**: Select LOCAL_MUSIC content from SoundTouch App Media Server
|
||||
- **Requirements**: SoundTouch App Media Server running on a computer
|
||||
- **Content Types**: Albums, tracks, artists, playlists
|
||||
- **Validation**: Requires both location and sourceAccount
|
||||
|
||||
#### `SelectStoredMusic(location, sourceAccount, itemName, containerArt string) error`
|
||||
- **Purpose**: Select STORED_MUSIC content from UPnP/DLNA media servers
|
||||
- **Requirements**: UPnP/DLNA media server (Windows Media Player, NAS, etc.)
|
||||
- **Content Types**: NAS libraries, network music collections
|
||||
- **Validation**: Requires both location and sourceAccount
|
||||
|
||||
### 2. CLI Commands
|
||||
|
||||
All API methods are exposed through comprehensive CLI commands:
|
||||
|
||||
#### `soundtouch-cli source internet-radio`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio" \
|
||||
--name "My Station" \
|
||||
--artwork "https://example.com/art.png"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source custom-radio`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source custom-radio \
|
||||
--url "https://stream.example.com/radio" \
|
||||
--name "My Station" \
|
||||
--artwork "https://example.com/art.png" \
|
||||
--service-url "http://localhost:8080"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source local-music`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source local-music \
|
||||
--location "album:983" \
|
||||
--account "3f205110-4a57-4e91-810a-123456789012" \
|
||||
--name "Welcome to the New"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source stored-music`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source stored-music \
|
||||
--location "6_a2874b5d_4f83d999" \
|
||||
--account "d09708a1-5953-44bc-a413-123456789012/0" \
|
||||
--name "Christmas Album"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source content` (Advanced)
|
||||
```bash
|
||||
soundtouch-cli --host <device> source content \
|
||||
--source LOCAL_INTERNET_RADIO \
|
||||
--location "https://stream.example.com/radio" \
|
||||
--name "My Stream" \
|
||||
--type stationurl \
|
||||
--presetable
|
||||
```
|
||||
|
||||
## 🧪 Test Coverage
|
||||
|
||||
Comprehensive test suites implemented for all new functionality:
|
||||
|
||||
### Unit Tests
|
||||
- **TestClient_SelectContentItem**: 5 test cases covering valid/invalid inputs
|
||||
- **TestClient_SelectLocalInternetRadio**: 4 test cases including streamUrl format
|
||||
- **TestClient_SelectLocalMusic**: 4 test cases with validation
|
||||
- **TestClient_SelectStoredMusic**: 4 test cases with error handling
|
||||
|
||||
### Test Coverage Summary
|
||||
- ✅ Valid content selection scenarios
|
||||
- ✅ streamUrl format validation
|
||||
- ✅ Parameter validation and error handling
|
||||
- ✅ Default value assignment
|
||||
- ✅ HTTP request formatting verification
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### Updated Documentation
|
||||
1. **CLI-REFERENCE.md**: Added comprehensive CLI command examples
|
||||
2. **Content Selection Example**: New `/examples/content-selection/` with working code
|
||||
3. **README Updates**: Added streamUrl format examples
|
||||
4. **API Documentation**: Inline Go documentation for all methods
|
||||
|
||||
### Example Code
|
||||
Complete working example demonstrating:
|
||||
- LOCAL_INTERNET_RADIO with streamUrl proxy format
|
||||
- LOCAL_INTERNET_RADIO with direct streams
|
||||
- LOCAL_MUSIC content selection
|
||||
- STORED_MUSIC content selection
|
||||
- Generic ContentItem usage
|
||||
|
||||
## 🔍 streamUrl Format Support
|
||||
|
||||
### What is the streamUrl Format?
|
||||
The streamUrl format uses a proxy server that accepts the actual stream URL as a parameter:
|
||||
|
||||
```
|
||||
http://contentapi.gmuth.de/station.php?name=StationName&streamUrl=ActualStreamURL
|
||||
```
|
||||
|
||||
### Implementation Details
|
||||
- **Full Support**: All streamUrl format URLs work seamlessly
|
||||
- **Example from Wiki**: Exact implementation matches the wiki specification
|
||||
- **ContentItem Structure**:
|
||||
```go
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
Location: "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp",
|
||||
IsPresetable: false,
|
||||
ItemName: "Antenne Chillout",
|
||||
ContainerArt: "https://www.radio.net/300/antennechillout.png",
|
||||
}
|
||||
```
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Design Principles
|
||||
1. **Consistency**: All methods follow the same parameter patterns
|
||||
2. **Flexibility**: `SelectContentItem()` allows maximum control
|
||||
3. **Convenience**: Specific methods (`SelectLocalInternetRadio()`, etc.) provide simpler interfaces
|
||||
4. **Validation**: Comprehensive input validation with clear error messages
|
||||
5. **Defaults**: Sensible defaults when optional parameters are empty
|
||||
|
||||
### ContentItem Construction
|
||||
All convenience methods create properly structured `ContentItem` objects:
|
||||
- Automatic `Type` assignment based on source
|
||||
- `IsPresetable` defaults to `true`
|
||||
- Default `ItemName` when not provided
|
||||
- Proper source-specific validation
|
||||
|
||||
## 🎵 Related Features
|
||||
|
||||
### Sibling Features (Also Implemented)
|
||||
Based on the wiki structure, these related features are also supported:
|
||||
|
||||
1. **LOCAL_MUSIC**: ✅ Fully implemented
|
||||
2. **STORED_MUSIC**: ✅ Fully implemented
|
||||
3. **SPOTIFY**: ✅ Previously implemented
|
||||
4. **TUNEIN**: ✅ Previously implemented
|
||||
5. **BLUETOOTH**: ✅ Previously implemented
|
||||
6. **AIRPLAY**: ✅ Previously implemented
|
||||
|
||||
## 📋 Usage Examples
|
||||
|
||||
### API Usage
|
||||
```go
|
||||
// streamUrl format
|
||||
location := "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio"
|
||||
err := client.SelectLocalInternetRadio(location, "", "My Station", "")
|
||||
|
||||
// Direct ContentItem
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
Location: location,
|
||||
ItemName: "My Station",
|
||||
IsPresetable: true,
|
||||
}
|
||||
err := client.SelectContentItem(contentItem)
|
||||
```
|
||||
|
||||
### CLI Usage
|
||||
```bash
|
||||
# streamUrl format
|
||||
soundtouch-cli --host 192.168.1.100 source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=MyStation&streamUrl=https://stream.example.com/radio" \
|
||||
--name "My Station"
|
||||
|
||||
# Direct stream
|
||||
soundtouch-cli --host 192.168.1.100 source internet-radio \
|
||||
--location "https://stream.example.com/radio" \
|
||||
--name "Direct Stream"
|
||||
```
|
||||
|
||||
## 🔗 References
|
||||
|
||||
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
|
||||
- [LOCAL_INTERNET_RADIO - streamUrl format](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_internet_radio---streamurl-format)
|
||||
- [LOCAL_MUSIC](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_music)
|
||||
- [Content Selection Example](../examples/content-selection/README.md)
|
||||
- [CLI Reference](guides/CLI-REFERENCE.md)
|
||||
- [Content Selection Example (Direct)](../examples/content-selection/)
|
||||
|
||||
## ✅ Verification
|
||||
|
||||
This implementation has been verified to:
|
||||
1. ✅ Support exact wiki specification for streamUrl format
|
||||
2. ✅ Handle all LOCAL_INTERNET_RADIO, LOCAL_MUSIC, and STORED_MUSIC scenarios
|
||||
3. ✅ Pass comprehensive test suite
|
||||
4. ✅ Work with CLI commands
|
||||
5. ✅ Include complete documentation and examples
|
||||
6. ✅ Maintain backward compatibility
|
||||
|
||||
**Status**: 🎉 **COMPLETE** - All requested content selection features are fully implemented and ready for use!
|
||||
@@ -0,0 +1,104 @@
|
||||
# Device Customization Setup Guide
|
||||
|
||||
This guide documents the manual steps required to configure your Bose SoundTouch device for customization using the SoundCork approach.
|
||||
|
||||
Based on: https://github.com/deborahgu/soundcork
|
||||
|
||||
## Overview
|
||||
|
||||
SoundCork allows you to customize your SoundTouch device by intercepting and modifying its firmware update process. This requires specific manual configuration steps to prepare your device.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Bose SoundTouch device
|
||||
- Network access to device
|
||||
- Administrative access to your router/network
|
||||
|
||||
## Configuration Steps
|
||||
|
||||
### Step 1: Prepare USB Drive
|
||||
- Insert USB stick into computer
|
||||
- Create remote services file: `touch /path/to/mounted/usb/root-directory/remote_services`
|
||||
|
||||
### Step 2: Connect to Device
|
||||
- Insert USB stick into SoundTouch 20 device
|
||||
- Restart device (unplug power, plug it back in)
|
||||
|
||||
### Step 3: Access Device via SSH or Telnet
|
||||
|
||||
After the restart, remote access is enabled.
|
||||
|
||||
#### Option A: SSH
|
||||
- SSH access: `ssh -oHostKeyAlgorithms=ssh-rsa root@<device-ip>`
|
||||
- Device will show network interfaces and system info
|
||||
- No password required for root access
|
||||
|
||||
Example output:
|
||||
```text
|
||||
gesellix@Mac Bose-SoundTouch % ssh -oHostKeyAlgorithms=ssh-rsa root@<device-ip>
|
||||
Last login: Sun Feb 1 19:12:47 2026
|
||||
eth0 Link encap:Ethernet HWaddr CA:FE:BA:BE:A3:25
|
||||
inet addr:<device-ip> Bcast:0.0.0.0 Mask:255.255.255.0
|
||||
lo Link encap:Local Loopback
|
||||
inet addr:127.0.0.1 Mask:255.0.0.0
|
||||
usb0 Link encap:Ethernet HWaddr CA:FE:BA:BE:1E:47
|
||||
inet addr:123.12.123.12 Bcast:0.0.0.0 Mask:255.255.255.252
|
||||
|
||||
Sun Feb 1 20:35:24 CET 2026
|
||||
|
||||
Device name: "A Sound Machine"
|
||||
Country EU, Region (not set)
|
||||
Module type: scm
|
||||
root@spotty:~#
|
||||
```
|
||||
|
||||
#### Option B: Telnet via Docker
|
||||
If you don't have a telnet client installed, you can use Docker:
|
||||
```bash
|
||||
docker run --rm -it alpine:edge ash -c 'apk add -U inetutils-telnet && telnet <device-ip> 23'
|
||||
```
|
||||
|
||||
Example output:
|
||||
```text
|
||||
Trying <device-ip>...
|
||||
Connected to <device-ip>.
|
||||
Escape character is '^]'.
|
||||
|
||||
... --- ..- -. -.. - --- ..- -.-. ....
|
||||
|
||||
____ ____ _____ _________
|
||||
/ __ )/ __ \/ ___// _______/
|
||||
/ __ / / / /\__ \/ __/
|
||||
____/ /_/ / /_/ /___/ / /___
|
||||
/_________/\____//____/_____/
|
||||
|
||||
|
||||
spotty login: root
|
||||
eth0 Link encap:Ethernet HWaddr CA:FE:BA:BE:A3:25
|
||||
inet addr:<device-ip> Bcast:0.0.0.0 Mask:255.255.255.0
|
||||
lo Link encap:Local Loopback
|
||||
inet addr:127.0.0.1 Mask:255.0.0.0
|
||||
usb0 Link encap:Ethernet HWaddr CA:FE:BA:BE:1E:47
|
||||
inet addr:123.12.123.12 Bcast:0.0.0.0 Mask:255.255.255.252
|
||||
|
||||
Sun Feb 1 19:12:47 CET 2026
|
||||
|
||||
Device name: "A Sound Machine"
|
||||
Country EU, Region (not set)
|
||||
Module type: scm
|
||||
root@spotty:~#
|
||||
```
|
||||
|
||||
### Step 4: Check Current Configuration
|
||||
- View current configuration: `cat /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml`
|
||||
- Note the URLs for streaming, stats, software updates, and BMX registry
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep your device's original firmware backed up
|
||||
- Ensure stable network connection during setup
|
||||
- Document your device's current firmware version before starting
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
*Common issues and solutions will be added here...*
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user