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>
173 lines
3.2 KiB
Go
173 lines
3.2 KiB
Go
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) }
|