mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 16:46:17 +00:00
Introduces a standalone `soundtouch-backup` CLI with three subcommands: - `all`: authenticates with the Bose cloud, backs up account data, then reads device IPs from devices.xml and backs up each reachable speaker - `cloud`: fetches account profile, devices, sources, presets, and full endpoint from streaming.bose.com - `local`: backs up each speaker via HTTP API (12 endpoints) and optionally via SSH (individual files + /opt/Bose/etc/ and /mnt/nv/BoseApp-Persistence/1/ directories) Also centralises pkg/service/ssh → pkg/ssh so both the service and the backup tool share the same SSH client; adds ReadFile and ReadDir methods, and handles the firmware quirk where cat exits 1 on empty files. Output is a single dated .tar.gz or .zip archive. Example flow: ```shell gesellix@Mac Bose-SoundTouch % go run ./cmd/soundtouch-backup all --output _/cloud-backup --email user@example.com Password: Authenticating as user@example.com... ✓ Authenticated (account ID: 1234567) ✓ email address (107 bytes) ✓ devices (1492 bytes) ✓ sources (1111 bytes) ✓ presets (2585 bytes) ✓ full account (55037 bytes) Found 2 device(s) in cloud account, attempting local backup... ✓ ST20: 12 files via HTTP ⚠ ST20: SSH skipped /etc/remote_services (Process exited with status 1) ⚠ ST20: SSH empty file /mnt/nv/remote_services ✓ ST20: 64 files via SSH ✓ ST10: 12 files via HTTP ⚠ ST10: SSH empty file /etc/remote_services ⚠ ST10: SSH skipped /mnt/nv/remote_services (Process exited with status 1) ✓ ST10: 48 files via SSH Archive written: _/cloud-backup/soundtouch-backup-2026-05-02.tar.gz (141 files) ``` --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
120 lines
2.9 KiB
Go
120 lines
2.9 KiB
Go
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
|
|
}
|