mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
v0.114.0 binaries reported version 0.0.0 in the web UI. Two root causes, both fixed here. 1. The release build relied solely on Go's VCS stamping of info.Main.Version and never injected a version. When v0.114.0 was re-released via workflow_dispatch from `main` (one commit past the tag) with a shallow checkout, no tag was reachable, so Go stamped a v0.0.0-<ts>-<sha> pseudo-version. The asset filenames used the validated input version, so the files were named v0.114.0 but reported 0.0.0 at runtime. 2. The `release` and `workflow_dispatch` triggers followed two distinct patterns. On `release` every job's checkout landed on the tagged commit (GITHUB_SHA == tag); on `workflow_dispatch` they all built whatever branch the run started from. So a manual dispatch built the wrong source entirely (binaries and Docker images alike). Changes: - Unify both triggers on the git tag. `validate` resolves the tag once (inputs.tag on dispatch, release.tag_name on a release event), verifies it exists in git, and exposes it as an output. Every other job checks out `ref: needs.validate.outputs.tag`, so the build is always the tagged commit regardless of trigger. The dispatch path now re-releases an existing tag (push the tag first) instead of creating one from a branch; it fails fast if the tag is missing. - Inject -X main.version/commit/date into the release binaries, mirroring the Dockerfile (which has done this since #422). version/commit no longer depend on git stamping; commit is read from the checked-out HEAD (not github.sha, which on dispatch is the branch HEAD). Both binaries and Docker images take the v-prefixed tag (needs.validate.outputs.tag) so the displayed version stays "v0.114.0", matching prior releases. - Guard updateBuildInfo() in all four cmd/*/main.go so an injected version (version != "dev") is never clobbered by a VCS pseudo-version. `go install …@vX.Y.Z` still resolves the tag via build info as before. - Collapse the duplicated `if event_name == workflow_dispatch` tag derivations and route tag/version through needs.validate.outputs.*. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
278 lines
8.5 KiB
Go
278 lines
8.5 KiB
Go
// Package main provides soundtouch-player, the LAN-resident web player for
|
|
// controlling Bose SoundTouch devices. It reaches speakers directly on the
|
|
// local network and optionally delegates cloud-only features (e.g. TTS) to a
|
|
// remote AfterTouch service via --service-url, which is why it stays useful
|
|
// when soundtouch-service runs off-LAN (e.g. in the cloud).
|
|
//
|
|
// It was previously named soundtouch-web; that name is no longer published.
|
|
// If you still run the binary under the old name, it prints a rename notice
|
|
// and otherwise behaves identically.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/urfave/cli/v2"
|
|
)
|
|
|
|
var (
|
|
version = "dev"
|
|
commit = "unknown"
|
|
date = "unknown"
|
|
repoURL = "https://github.com/gesellix/bose-soundtouch"
|
|
)
|
|
|
|
func updateBuildInfo() {
|
|
if info, ok := debug.ReadBuildInfo(); ok {
|
|
if info.Main.Path != "" {
|
|
repoURL = "https://" + info.Main.Path
|
|
}
|
|
|
|
// Only fall back to build info when the version was not injected via
|
|
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
|
|
// This keeps an explicitly stamped release version from being clobbered
|
|
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
|
|
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
|
|
version = info.Main.Version
|
|
}
|
|
|
|
for _, setting := range info.Settings {
|
|
switch setting.Key {
|
|
case "vcs.revision":
|
|
commit = setting.Value
|
|
case "vcs.time":
|
|
if t, err := time.Parse(time.RFC3339, setting.Value); err == nil {
|
|
date = t.Format("2006-01-02 15:04:05")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// warnIfInvokedAsWeb prints a one-line deprecation notice when the binary is
|
|
// run under its old name (soundtouch-web). That name is no longer published,
|
|
// but anyone who renamed the binary still gets nudged to soundtouch-player.
|
|
func warnIfInvokedAsWeb() {
|
|
if len(os.Args) == 0 {
|
|
return
|
|
}
|
|
|
|
name := filepath.Base(os.Args[0])
|
|
if name == "soundtouch-web" || name == "soundtouch-web.exe" {
|
|
log.Println("notice: 'soundtouch-web' has been renamed to 'soundtouch-player'. " +
|
|
"The 'soundtouch-web' name is no longer published; please switch to 'soundtouch-player'.")
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
updateBuildInfo()
|
|
warnIfInvokedAsWeb()
|
|
|
|
app := &cli.App{
|
|
Name: "soundtouch-player",
|
|
Usage: "LAN web player 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"},
|
|
},
|
|
&cli.StringSliceFlag{
|
|
Name: "devices",
|
|
Usage: "SoundTouch device IP address(es) to add manually (can be specified multiple times)",
|
|
EnvVars: []string{"SOUNDTOUCH_DEVICES"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "service-url",
|
|
Usage: "AfterTouch service base URL (e.g. https://soundtouch.local). Required for custom stream URLs to work as presets via LOCAL_INTERNET_RADIO",
|
|
EnvVars: []string{"SERVICE_URL"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "service-ca",
|
|
Usage: "Path to the AfterTouch service CA certificate (PEM) to trust for server-side calls such as TTS. Typically the service's <dataDir>/certs/ca.crt. Appended to the system trust store",
|
|
EnvVars: []string{"SERVICE_CA"},
|
|
},
|
|
},
|
|
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", sanitizeLog(rawBind), sanitizeLog(bindAddr))
|
|
}
|
|
|
|
rawIface := c.String("interface")
|
|
manualHosts := c.StringSlice("devices")
|
|
|
|
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
|
|
if rawIface == "" && ifaceName != "" {
|
|
log.Printf("Defaulting --interface to %q from --bind", sanitizeLog(ifaceName))
|
|
}
|
|
|
|
addr := ":" + port
|
|
if bindAddr != "" {
|
|
addr = bindAddr + ":" + port
|
|
}
|
|
|
|
// Create web app without templates (SPA mode)
|
|
webApp := soundtouchweb.NewWebApp()
|
|
webApp.Version = version
|
|
webApp.Commit = commit
|
|
webApp.Date = date
|
|
webApp.RepoURL = repoURL
|
|
webApp.ServiceURL = strings.TrimRight(c.String("service-url"), "/")
|
|
|
|
if caPath := c.String("service-ca"); caPath != "" {
|
|
client, err := soundtouchweb.NewServiceHTTPClient(caPath)
|
|
if err != nil {
|
|
log.Fatalf("--service-ca: %v", err)
|
|
}
|
|
|
|
webApp.ServiceClient = client
|
|
|
|
log.Printf("Trusting AfterTouch service CA from %s", sanitizeLog(caPath))
|
|
}
|
|
|
|
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
|
|
|
|
// Discover devices on startup
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
|
|
|
|
for _, host := range manualHosts {
|
|
webApp.AddDeviceByHost(host, 8090, "manual")
|
|
}
|
|
|
|
webApp.DiscoverDevices(ctx, discoveryService)
|
|
|
|
webApp.BroadcastDiscoveryStatus("completed", webApp.DeviceCount())
|
|
webApp.BroadcastDeviceList()
|
|
}()
|
|
|
|
r := chi.NewRouter()
|
|
webApp.Mount(r, discoveryService)
|
|
|
|
log.Printf("AfterTouch Web UI starting on http://%s", sanitizeLog(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)
|
|
}
|
|
}
|