diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index 90aba4d3..265a7258 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -37,7 +37,7 @@ jobs: run: test -z "$(gofmt -l .)" - name: Build daemon - run: go build -v ./cmd/daemon + run: go build -v -o /dev/null ./cmd/daemon - name: Test run: go test -v -tags "test_unit test_integration" ./... diff --git a/Dockerfile b/Dockerfile index 9dd379cd..7f8e8926 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,12 +8,12 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=1 go build -v ./cmd/daemon +RUN CGO_ENABLED=1 go build -v -o ./go-librespot ./cmd/daemon FROM alpine:3.23 RUN apk update && apk -U --no-cache add libpulse avahi libgcc gcompat alsa-lib -COPY --from=build /src/daemon /usr/bin/go-librespot +COPY --from=build /src/go-librespot /usr/bin/go-librespot CMD ["/usr/bin/go-librespot", "--config_dir", "/config"] \ No newline at end of file diff --git a/cmd/daemon/cli_config.go b/cmd/daemon/cli_config.go new file mode 100644 index 00000000..eb8e73f2 --- /dev/null +++ b/cmd/daemon/cli_config.go @@ -0,0 +1,238 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/devgianlu/go-librespot/daemon" + "github.com/gofrs/flock" + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/confmap" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/providers/posflag" + "github.com/knadh/koanf/v2" + log "github.com/sirupsen/logrus" + flag "github.com/spf13/pflag" +) + +var errAlreadyRunning = errors.New("go-librespot is already running") + +type cliConfig struct { + ConfigDir string `koanf:"config_dir"` + + // Keep this around so the lockfile finalizer doesn't release it. + configLock *flock.Flock + + LogLevel log.Level `koanf:"log_level"` + LogDisableTimestamp bool `koanf:"log_disable_timestamp"` + + DeviceId string `koanf:"device_id"` + DeviceName string `koanf:"device_name"` + DeviceType string `koanf:"device_type"` + ClientToken string `koanf:"client_token"` + + AudioBackend string `koanf:"audio_backend"` + AudioBackendRuntimeSocket string `koanf:"audio_backend_runtime_socket"` + AudioDevice string `koanf:"audio_device"` + MixerDevice string `koanf:"mixer_device"` + MixerControlName string `koanf:"mixer_control_name"` + AudioBufferTime int `koanf:"audio_buffer_time"` + AudioPeriodCount int `koanf:"audio_period_count"` + AudioOutputPipe string `koanf:"audio_output_pipe"` + AudioOutputPipeFormat string `koanf:"audio_output_pipe_format"` + + Bitrate int `koanf:"bitrate"` + VolumeSteps uint32 `koanf:"volume_steps"` + InitialVolume uint32 `koanf:"initial_volume"` + IgnoreLastVolume bool `koanf:"ignore_last_volume"` + NormalisationDisabled bool `koanf:"normalisation_disabled"` + NormalisationUseAlbumGain bool `koanf:"normalisation_use_album_gain"` + NormalisationPregain float32 `koanf:"normalisation_pregain"` + ExternalVolume bool `koanf:"external_volume"` + ZeroconfEnabled bool `koanf:"zeroconf_enabled"` + ZeroconfPort int `koanf:"zeroconf_port"` + ZeroconfBackend string `koanf:"zeroconf_backend"` + DisableAutoplay bool `koanf:"disable_autoplay"` + ZeroconfInterfacesToAdvertise []string `koanf:"zeroconf_interfaces_to_advertise"` + MprisEnabled bool `koanf:"mpris_enabled"` + FlacEnabled bool `koanf:"flac_enabled"` + + Server struct { + Enabled bool `koanf:"enabled"` + Address string `koanf:"address"` + Port int `koanf:"port"` + AllowOrigin string `koanf:"allow_origin"` + CertFile string `koanf:"cert_file"` + KeyFile string `koanf:"key_file"` + + ImageSize string `koanf:"image_size"` + } `koanf:"server"` + + Credentials struct { + Type string `koanf:"type"` + Interactive struct { + CallbackPort int `koanf:"callback_port"` + } `koanf:"interactive"` + SpotifyToken struct { + Username string `koanf:"username"` + AccessToken string `koanf:"access_token"` + } `koanf:"spotify_token"` + Zeroconf struct { + PersistCredentials bool `koanf:"persist_credentials"` + } `koanf:"zeroconf"` + } `koanf:"credentials"` +} + +func (c *cliConfig) toDaemonConfig() *daemon.Config { + dc := &daemon.Config{ + DeviceId: c.DeviceId, + DeviceName: c.DeviceName, + DeviceType: c.DeviceType, + ClientToken: c.ClientToken, + + AudioBackend: c.AudioBackend, + AudioBackendRuntimeSocket: c.AudioBackendRuntimeSocket, + AudioDevice: c.AudioDevice, + MixerDevice: c.MixerDevice, + MixerControlName: c.MixerControlName, + AudioBufferTime: c.AudioBufferTime, + AudioPeriodCount: c.AudioPeriodCount, + AudioOutputPipe: c.AudioOutputPipe, + AudioOutputPipeFormat: c.AudioOutputPipeFormat, + + Bitrate: c.Bitrate, + VolumeSteps: c.VolumeSteps, + InitialVolume: c.InitialVolume, + IgnoreLastVolume: c.IgnoreLastVolume, + NormalisationDisabled: c.NormalisationDisabled, + NormalisationUseAlbumGain: c.NormalisationUseAlbumGain, + NormalisationPregain: c.NormalisationPregain, + ExternalVolume: c.ExternalVolume, + DisableAutoplay: c.DisableAutoplay, + + ZeroconfEnabled: c.ZeroconfEnabled, + ZeroconfPort: c.ZeroconfPort, + ZeroconfBackend: c.ZeroconfBackend, + ZeroconfInterfacesToAdvertise: c.ZeroconfInterfacesToAdvertise, + + FlacEnabled: c.FlacEnabled, + ImageSize: c.Server.ImageSize, + } + dc.Credentials.Type = c.Credentials.Type + dc.Credentials.Interactive.CallbackPort = c.Credentials.Interactive.CallbackPort + dc.Credentials.SpotifyToken.Username = c.Credentials.SpotifyToken.Username + dc.Credentials.SpotifyToken.AccessToken = c.Credentials.SpotifyToken.AccessToken + dc.Credentials.Zeroconf.PersistCredentials = c.Credentials.Zeroconf.PersistCredentials + return dc +} + +func loadCLIConfig(cfg *cliConfig) error { + f := flag.NewFlagSet("config", flag.ContinueOnError) + f.Usage = func() { + fmt.Println(f.FlagUsages()) + os.Exit(0) + } + userConfigDir, err := os.UserConfigDir() + if err != nil { + return err + } + defaultConfigDir := filepath.Join(userConfigDir, "go-librespot") + f.StringVar(&cfg.ConfigDir, "config_dir", defaultConfigDir, "the configuration directory") + + var configOverrides []string + f.StringArrayVarP(&configOverrides, "conf", "c", nil, "override config values (format: field=value, use field1.field2=value for nested fields)") + + if err := f.Parse(os.Args[1:]); err != nil { + return err + } + + if err := os.MkdirAll(cfg.ConfigDir, 0o700); err != nil { + return fmt.Errorf("failed creating config directory: %w", err) + } + + lockFilePath := filepath.Join(cfg.ConfigDir, "lockfile") + cfg.configLock = flock.New(lockFilePath) + if locked, err := cfg.configLock.TryLock(); err != nil { + return fmt.Errorf("could not lock config directory: %w", err) + } else if !locked { + return fmt.Errorf("%w (lockfile: %s)", errAlreadyRunning, lockFilePath) + } + + k := koanf.New(".") + + _ = k.Load(confmap.Provider(map[string]interface{}{ + "log_level": log.InfoLevel, + + "device_type": "computer", + "bitrate": 160, + + "audio_backend": "alsa", + "audio_device": "default", + "audio_output_pipe_format": "s16le", + "mixer_control_name": "Master", + + "volume_steps": 100, + "initial_volume": 100, + + "credentials.type": "zeroconf", + + "zeroconf_backend": "builtin", + + "server.address": "localhost", + "server.image_size": "default", + }, "."), nil) + + var configPath string + if _, err := os.Stat(filepath.Join(cfg.ConfigDir, "config.yaml")); os.IsNotExist(err) { + configPath = filepath.Join(cfg.ConfigDir, "config.yml") + } else { + configPath = filepath.Join(cfg.ConfigDir, "config.yaml") + } + + if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("failed reading configuration file: %w", err) + } + } + + if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil { + return fmt.Errorf("failed loading command line configuration: %w", err) + } + + if len(configOverrides) > 0 { + overrideMap := make(map[string]interface{}) + for _, override := range configOverrides { + parts := strings.SplitN(override, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid config override format: %s (expected field=value)", override) + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if key == "" { + return fmt.Errorf("invalid config override: empty field name in %s", override) + } + overrideMap[key] = value + } + if err := k.Load(confmap.Provider(overrideMap, "."), nil); err != nil { + return fmt.Errorf("failed loading config overrides: %w", err) + } + } + + if err := k.Unmarshal("", &cfg); err != nil { + return fmt.Errorf("failed to unmarshal configuration: %w", err) + } + + if cfg.DeviceName == "" { + cfg.DeviceName = "go-librespot" + + hostname, _ := os.Hostname() + if hostname != "" { + cfg.DeviceName += " " + hostname + } + } + + return nil +} diff --git a/cmd/daemon/file_state_store.go b/cmd/daemon/file_state_store.go new file mode 100644 index 00000000..bbe00730 --- /dev/null +++ b/cmd/daemon/file_state_store.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + librespot "github.com/devgianlu/go-librespot" +) + +type FileStateStore struct { + statePath string + credentialsPath string + + log librespot.Logger +} + +func NewFileStateStore(statePath, credentialsPath string, log librespot.Logger) *FileStateStore { + return &FileStateStore{ + statePath: statePath, + credentialsPath: credentialsPath, + log: log, + } +} + +func (s *FileStateStore) Load() (*librespot.AppState, error) { + state := &librespot.AppState{} + + content, err := os.ReadFile(s.statePath) + switch { + case err == nil: + if err := json.Unmarshal(content, state); err != nil { + return nil, fmt.Errorf("failed unmarshalling state file: %w", err) + } + s.log.Debugf("app state loaded") + case errors.Is(err, os.ErrNotExist): + s.log.Debugf("no app state found") + default: + return nil, fmt.Errorf("failed reading state file: %w", err) + } + + // Legacy credentials.json fallback for installations that predate the + // merged state.json layout. + if state.Credentials.Username == "" && s.credentialsPath != "" { + content, err := os.ReadFile(s.credentialsPath) + switch { + case err == nil: + if err := json.Unmarshal(content, &state.Credentials); err != nil { + return nil, fmt.Errorf("failed unmarshalling stored credentials file: %w", err) + } + s.log.WithField("username", librespot.ObfuscateUsername(state.Credentials.Username)). + Debugf("stored credentials found") + case errors.Is(err, os.ErrNotExist): + s.log.Debugf("stored credentials not found") + default: + return nil, fmt.Errorf("failed reading credentials file: %w", err) + } + } + + return state, nil +} + +func (s *FileStateStore) Save(state *librespot.AppState) error { + state.Lock() + defer state.Unlock() + + tmpFile, err := os.CreateTemp(filepath.Dir(s.statePath), filepath.Base(s.statePath)+".*.tmp") + if err != nil { + return fmt.Errorf("failed creating temporary file for app state: %w", err) + } + + if err := json.NewEncoder(tmpFile).Encode(state); err != nil { + return fmt.Errorf("failed writing marshalled app state: %w", err) + } + + if err := os.Rename(tmpFile.Name(), s.statePath); err != nil { + return fmt.Errorf("failed replacing app state file: %w", err) + } + + return nil +} diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 6008cd26..9914c84d 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -2,632 +2,83 @@ package main import ( "context" - "encoding/hex" "errors" "fmt" - "math" - "net/http" "os" + "os/signal" "path/filepath" - "strings" + "syscall" "time" librespot "github.com/devgianlu/go-librespot" + "github.com/devgianlu/go-librespot/daemon" "github.com/devgianlu/go-librespot/mpris" - "github.com/devgianlu/go-librespot/playplay" - - "github.com/devgianlu/go-librespot/apresolve" - "github.com/devgianlu/go-librespot/player" - devicespb "github.com/devgianlu/go-librespot/proto/spotify/connectstate/devices" - "github.com/devgianlu/go-librespot/session" - "github.com/devgianlu/go-librespot/zeroconf" - "github.com/gofrs/flock" log "github.com/sirupsen/logrus" "golang.org/x/exp/rand" - - "github.com/knadh/koanf/v2" - flag "github.com/spf13/pflag" - - "github.com/knadh/koanf/parsers/yaml" - "github.com/knadh/koanf/providers/confmap" - "github.com/knadh/koanf/providers/file" - "github.com/knadh/koanf/providers/posflag" ) -var errAlreadyRunning = errors.New("go-librespot is already running") - -type App struct { - log librespot.Logger - cfg *Config - - client *http.Client - - resolver *apresolve.ApResolver - - deviceId string - deviceType devicespb.DeviceType - clientToken string - state librespot.AppState - - server ApiServer - mpris mpris.Server - logoutCh chan *AppPlayer -} - -func parseDeviceType(val string) (devicespb.DeviceType, error) { - valEnum, ok := devicespb.DeviceType_value[strings.ToUpper(val)] - if !ok { - return 0, fmt.Errorf("invalid device type: %s", val) - } - - return devicespb.DeviceType(valEnum), nil -} - -func NewApp(cfg *Config) (app *App, err error) { - app = &App{cfg: cfg, logoutCh: make(chan *AppPlayer)} - - logger := log.StandardLogger() - logger.SetFormatter(&log.TextFormatter{ - DisableTimestamp: cfg.LogDisableTimestamp, - }) - - app.log = &LogrusAdapter{log.NewEntry(logger)} - app.client = &http.Client{Timeout: 30 * time.Second} - - app.deviceType, err = parseDeviceType(cfg.DeviceType) - if err != nil { - return nil, err - } - - app.state.SetLogger(app.log) - if err := app.state.Read(cfg.ConfigDir); err != nil { - return nil, err - } - - app.resolver = apresolve.NewApResolver(app.log, app.client) - - if cfg.DeviceId != "" { - // Use configured device ID. - app.deviceId = cfg.DeviceId - } else if app.state.DeviceId != "" { - // Use device ID generated in a previous run. - app.deviceId = app.state.DeviceId - } else { - // Generate a new device ID. - deviceIdBytes := make([]byte, 20) - _, _ = rand.Read(deviceIdBytes) - app.deviceId = hex.EncodeToString(deviceIdBytes) - log.Infof("generated new device id: %s", app.deviceId) - - // Save device ID so we can reuse it next time. - app.state.DeviceId = app.deviceId - - if err := app.state.Write(); err != nil { - return nil, err - } - } - - if cfg.ClientToken != "" { - app.clientToken = cfg.ClientToken - } - - if cfg.FlacEnabled && !playplay.Plugin.IsSupported() { - // FLAC decryption keys are available only with the PlayPlay DRM implementation. - // Using PlayPlay might get you banned by Spotify. - return nil, fmt.Errorf("FLAC playback requires a PlapPlay implementation") - } - - return app, nil -} - -func (app *App) newAppPlayer(ctx context.Context, creds any) (_ *AppPlayer, err error) { - appPlayer := &AppPlayer{ - app: app, - stop: make(chan struct{}, 1), - logout: app.logoutCh, - countryCode: new(string), - volumeUpdate: make(chan float32, 1), - playbackReadyCh: make(chan struct{}), - } - - appPlayer.prefetchTimer = time.NewTimer(math.MaxInt64) - appPlayer.prefetchTimer.Stop() - - if appPlayer.sess, err = session.NewSessionFromOptions(ctx, &session.Options{ - Log: app.log, - DeviceType: app.deviceType, - DeviceId: app.deviceId, - ClientToken: app.clientToken, - Resolver: app.resolver, - Client: app.client, - AppState: &app.state, - Credentials: creds, - }); err != nil { - return nil, err - } - - appPlayer.initState() - - if appPlayer.player, err = player.NewPlayer(&player.Options{ - Spclient: appPlayer.sess.Spclient(), - AudioKey: appPlayer.sess.AudioKey(), - Events: appPlayer.sess.Events(), - Log: app.log, - - FlacEnabled: app.cfg.FlacEnabled, - - NormalisationEnabled: !app.cfg.NormalisationDisabled, - NormalisationUseAlbumGain: app.cfg.NormalisationUseAlbumGain, - NormalisationPregain: app.cfg.NormalisationPregain, - - CountryCode: appPlayer.countryCode, - - AudioBackend: app.cfg.AudioBackend, - AudioBackendRuntimeSocket: app.cfg.AudioBackendRuntimeSocket, - AudioDevice: app.cfg.AudioDevice, - MixerDevice: app.cfg.MixerDevice, - MixerControlName: app.cfg.MixerControlName, - - AudioBufferTime: app.cfg.AudioBufferTime, - AudioPeriodCount: app.cfg.AudioPeriodCount, - - ExternalVolume: app.cfg.ExternalVolume, - VolumeUpdate: appPlayer.volumeUpdate, - - AudioOutputPipe: app.cfg.AudioOutputPipe, - AudioOutputPipeFormat: app.cfg.AudioOutputPipeFormat, - }, - ); err != nil { - return nil, fmt.Errorf("failed initializing player: %w", err) - } - - return appPlayer, nil -} - -func (app *App) Zeroconf(ctx context.Context) error { - return app.withAppPlayer(ctx, func(ctx context.Context) (*AppPlayer, error) { - if app.cfg.Credentials.Zeroconf.PersistCredentials && len(app.state.Credentials.Data) > 0 { - app.log.WithField("username", librespot.ObfuscateUsername(app.state.Credentials.Username)). - Infof("loading previously persisted zeroconf credentials") - return app.newAppPlayer(ctx, session.StoredCredentials{ - Username: app.state.Credentials.Username, - Data: app.state.Credentials.Data, - }) - } - - return nil, nil - }) -} - -func (app *App) SpotifyToken(ctx context.Context, username, token string) error { - return app.withCredentials(ctx, session.SpotifyTokenCredentials{Username: username, Token: token}) -} - -func (app *App) Interactive(ctx context.Context, callbackPort int) error { - return app.withCredentials(ctx, session.InteractiveCredentials{CallbackPort: callbackPort}) -} - -func (app *App) withCredentials(ctx context.Context, creds any) (err error) { - return app.withAppPlayer(ctx, func(ctx context.Context) (*AppPlayer, error) { - if len(app.state.Credentials.Data) > 0 { - return app.newAppPlayer(ctx, session.StoredCredentials{ - Username: app.state.Credentials.Username, - Data: app.state.Credentials.Data, - }) - } else { - appPlayer, err := app.newAppPlayer(ctx, creds) - if err != nil { - return nil, err - } - - // store credentials outside this context in case we get called again - app.state.Credentials.Username = appPlayer.sess.Username() - app.state.Credentials.Data = appPlayer.sess.StoredCredentials() - - if err = app.state.Write(); err != nil { - return nil, err - } - - app.log.WithField("username", librespot.ObfuscateUsername(appPlayer.sess.Username())). - Debugf("stored credentials") - return appPlayer, nil - } - }) -} - -func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Context) (*AppPlayer, error)) (err error) { - // if zeroconf is disabled, there is not much we need to do - if !app.cfg.ZeroconfEnabled { - appPlayer, err := appPlayerFunc(ctx) - if err != nil { - return err - } else if appPlayer == nil { - panic("zeroconf is disabled and no credentials are present") - } - - appPlayer.Run(ctx, app.server.Receive(), app.mpris.Receive()) - return nil - } - - // pre fetch resolver endpoints - if err := app.resolver.FetchAll(ctx); err != nil { - return fmt.Errorf("failed getting endpoints from resolver: %w", err) - } - - // start zeroconf server and dispatch - z, err := zeroconf.NewZeroconf(app.log, app.cfg.ZeroconfPort, app.cfg.DeviceName, app.deviceId, app.deviceType, app.cfg.ZeroconfInterfacesToAdvertise, app.cfg.ZeroconfBackend == "avahi") - if err != nil { - return fmt.Errorf("failed initializing zeroconf: %w", err) - } - - var apiCh chan ApiRequest - - currentPlayer, err := appPlayerFunc(ctx) - if err != nil { - return err - } - - // set current player to the provided one if we have it - if currentPlayer != nil { - app.log.WithField("username", librespot.ObfuscateUsername(currentPlayer.sess.Username())). - Debugf("initializing zeroconf session") - - apiCh = make(chan ApiRequest) - go currentPlayer.Run(ctx, apiCh, app.mpris.Receive()) - - // let zeroconf know that we already have a user - z.SetCurrentUser(currentPlayer.sess.Username()) - } - - // forward API requests to proper channel only if a session is present - go func() { - for { - select { - case req := <-app.server.Receive(): - if currentPlayer == nil { - if req.Type == ApiRequestTypeRoot { - req.Reply(&ApiResponseRoot{}, nil) - } else { - req.Reply(nil, ErrNoSession) - } - break - } - - // if we are here the channel must exist - apiCh <- req - } - } - }() - - // listen for logout events and unset session when that happens - go func() { - for { - select { - case p := <-app.logoutCh: - // check that the logout request is for the current player - if p != currentPlayer { - continue - } - - currentPlayer.Close() - currentPlayer = nil - - // close the channel after setting the current session to nil - close(apiCh) - - // restore the session if there is one. - // we will restore the session even if it's for the same user, but it shouldn't be an issue - newAppPlayer, err := appPlayerFunc(ctx) - if err != nil { - log.WithError(err).Errorf("failed restoring session after logout") - - // unset the zeroconf user - z.SetCurrentUser("") - } else if newAppPlayer == nil { - // unset the zeroconf user - z.SetCurrentUser("") - } else { - // first create the channel and then assign the current session - apiCh = make(chan ApiRequest) - currentPlayer = newAppPlayer - - go newAppPlayer.Run(ctx, apiCh, app.mpris.Receive()) - - // let zeroconf know that we already have a user - z.SetCurrentUser(newAppPlayer.sess.Username()) - - app.log.WithField("username", librespot.ObfuscateUsername(currentPlayer.sess.Username())). - Debugf("restored session after logout") - } - } - } - }() - - return z.Serve(func(req zeroconf.NewUserRequest) bool { - if currentPlayer != nil { - currentPlayer.Close() - currentPlayer = nil - - // close the channel after setting the current session to nil - close(apiCh) - - // no need to unset the zeroconf user here as the new one will overwrite it anyway - } - - newAppPlayer, err := app.newAppPlayer(ctx, session.BlobCredentials{ - Username: req.Username, - Blob: req.AuthBlob, - }) - if err != nil { - app.log.WithError(err).WithField("username", librespot.ObfuscateUsername(req.Username)). - Errorf("failed creating new session from %s", req.DeviceName) - return false - } - - // first create the channel and then assign the current session - apiCh = make(chan ApiRequest) - currentPlayer = newAppPlayer - - if app.cfg.Credentials.Zeroconf.PersistCredentials { - app.state.Credentials.Username = newAppPlayer.sess.Username() - app.state.Credentials.Data = newAppPlayer.sess.StoredCredentials() - - if err := app.state.Write(); err != nil { - log.WithError(err).Errorf("failed persisting zeroconf credentials") - } - - app.log.WithField("username", librespot.ObfuscateUsername(newAppPlayer.sess.Username())). - Debugf("persisted zeroconf credentials") - } - - go newAppPlayer.Run(ctx, apiCh, app.mpris.Receive()) - return true - }) -} - -type Config struct { - ConfigDir string `koanf:"config_dir"` - - // We need to keep this object around, otherwise it gets GC'd and the - // finalizer will run, probably closing the lock. - configLock *flock.Flock - - LogLevel log.Level `koanf:"log_level"` - LogDisableTimestamp bool `koanf:"log_disable_timestamp"` - DeviceId string `koanf:"device_id"` - DeviceName string `koanf:"device_name"` - DeviceType string `koanf:"device_type"` - ClientToken string `koanf:"client_token"` - AudioBackend string `koanf:"audio_backend"` - AudioBackendRuntimeSocket string `koanf:"audio_backend_runtime_socket"` - AudioDevice string `koanf:"audio_device"` - MixerDevice string `koanf:"mixer_device"` - MixerControlName string `koanf:"mixer_control_name"` - AudioBufferTime int `koanf:"audio_buffer_time"` - AudioPeriodCount int `koanf:"audio_period_count"` - AudioOutputPipe string `koanf:"audio_output_pipe"` - AudioOutputPipeFormat string `koanf:"audio_output_pipe_format"` - Bitrate int `koanf:"bitrate"` - VolumeSteps uint32 `koanf:"volume_steps"` - InitialVolume uint32 `koanf:"initial_volume"` - IgnoreLastVolume bool `koanf:"ignore_last_volume"` - NormalisationDisabled bool `koanf:"normalisation_disabled"` - NormalisationUseAlbumGain bool `koanf:"normalisation_use_album_gain"` - NormalisationPregain float32 `koanf:"normalisation_pregain"` - ExternalVolume bool `koanf:"external_volume"` - ZeroconfEnabled bool `koanf:"zeroconf_enabled"` - ZeroconfPort int `koanf:"zeroconf_port"` - ZeroconfBackend string `koanf:"zeroconf_backend"` - DisableAutoplay bool `koanf:"disable_autoplay"` - ZeroconfInterfacesToAdvertise []string `koanf:"zeroconf_interfaces_to_advertise"` - MprisEnabled bool `koanf:"mpris_enabled"` - FlacEnabled bool `koanf:"flac_enabled"` - Server struct { - Enabled bool `koanf:"enabled"` - Address string `koanf:"address"` - Port int `koanf:"port"` - AllowOrigin string `koanf:"allow_origin"` - CertFile string `koanf:"cert_file"` - KeyFile string `koanf:"key_file"` - - ImageSize string `koanf:"image_size"` - } `koanf:"server"` - Credentials struct { - Type string `koanf:"type"` - Interactive struct { - CallbackPort int `koanf:"callback_port"` - } `koanf:"interactive"` - SpotifyToken struct { - Username string `koanf:"username"` - AccessToken string `koanf:"access_token"` - } `koanf:"spotify_token"` - Zeroconf struct { - PersistCredentials bool `koanf:"persist_credentials"` - } `koanf:"zeroconf"` - } `koanf:"credentials"` -} - -func loadConfig(cfg *Config) error { - f := flag.NewFlagSet("config", flag.ContinueOnError) - f.Usage = func() { - fmt.Println(f.FlagUsages()) - os.Exit(0) - } - userConfigDir, err := os.UserConfigDir() - if err != nil { - return err - } - defaultConfigDir := filepath.Join(userConfigDir, "go-librespot") - f.StringVar(&cfg.ConfigDir, "config_dir", defaultConfigDir, "the configuration directory") - - var configOverrides []string - f.StringArrayVarP(&configOverrides, "conf", "c", nil, "override config values (format: field=value, use field1.field2=value for nested fields)") - - err = f.Parse(os.Args[1:]) - if err != nil { - return err - } - - // Make config directory if needed. - err = os.MkdirAll(cfg.ConfigDir, 0o700) - if err != nil { - return fmt.Errorf("failed creating config directory: %w", err) - } - - // Lock the config directory (to ensure multiple instances won't clobber - // each others state). - lockFilePath := filepath.Join(cfg.ConfigDir, "lockfile") - cfg.configLock = flock.New(lockFilePath) - if locked, err := cfg.configLock.TryLock(); err != nil { - return fmt.Errorf("could not lock config directory: %w", err) - } else if !locked { - // Lock already taken! Looks like go-librespot is already running. - return fmt.Errorf("%w (lockfile: %s)", errAlreadyRunning, lockFilePath) - } - - k := koanf.New(".") - - // load default configuration - _ = k.Load(confmap.Provider(map[string]interface{}{ - "log_level": log.InfoLevel, - - "device_type": "computer", - "bitrate": 160, - - "audio_backend": "alsa", - "audio_device": "default", - "audio_output_pipe_format": "s16le", - "mixer_control_name": "Master", - - "volume_steps": 100, - "initial_volume": 100, - - "credentials.type": "zeroconf", - - "zeroconf_backend": "builtin", - - "server.address": "localhost", - "server.image_size": "default", - }, "."), nil) - - // load file configuration (if available) - var configPath string - if _, err := os.Stat(filepath.Join(cfg.ConfigDir, "config.yaml")); os.IsNotExist(err) { - configPath = filepath.Join(cfg.ConfigDir, "config.yml") - } else { - configPath = filepath.Join(cfg.ConfigDir, "config.yaml") - } - - if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("failed reading configuration file: %w", err) - } - } - - // load command line configuration - if err := k.Load(posflag.Provider(f, ".", k), nil); err != nil { - return fmt.Errorf("failed loading command line configuration: %w", err) - } - - // apply command line config overrides (-c/--conf flags) - if len(configOverrides) > 0 { - overrideMap := make(map[string]interface{}) - for _, override := range configOverrides { - parts := strings.SplitN(override, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid config override format: %s (expected field=value)", override) - } - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - if key == "" { - return fmt.Errorf("invalid config override: empty field name in %s", override) - } - overrideMap[key] = value - } - if err := k.Load(confmap.Provider(overrideMap, "."), nil); err != nil { - return fmt.Errorf("failed loading config overrides: %w", err) - } - } - - // unmarshal configuration - if err := k.Unmarshal("", &cfg); err != nil { - return fmt.Errorf("failed to unmarshal configuration: %w", err) - } - - if cfg.DeviceName == "" { - cfg.DeviceName = "go-librespot" - - hostname, _ := os.Hostname() - if hostname != "" { - cfg.DeviceName += " " + hostname - } - } - - return nil -} - func main() { rand.Seed(uint64(time.Now().UnixNano())) - var cfg Config - if err := loadConfig(&cfg); err != nil { + var cfg cliConfig + if err := loadCLIConfig(&cfg); err != nil { if errors.Is(err, errAlreadyRunning) { - // Print a nice error message instead of a harder-to-read log message. _, _ = fmt.Fprintln(os.Stderr, "could not start:", err) os.Exit(1) } log.WithError(err).Fatal("failed loading config") } - // set log level + logger := log.StandardLogger() + logger.SetFormatter(&log.TextFormatter{ + DisableTimestamp: cfg.LogDisableTimestamp, + }) log.SetLevel(cfg.LogLevel) - log.Infof("running go-librespot %s", librespot.VersionNumberString()) + logEntry := &LogrusAdapter{Log: log.NewEntry(logger)} - // create new app - app, err := NewApp(&cfg) - if err != nil { - log.WithError(err).Fatal("failed creating app") - } + logger.Infof("running go-librespot %s", librespot.VersionNumberString()) + + store := NewFileStateStore( + filepath.Join(cfg.ConfigDir, "state.json"), + filepath.Join(cfg.ConfigDir, "credentials.json"), + logEntry, + ) - // create api server if needed + var apiServer daemon.ApiServer if cfg.Server.Enabled { - app.server, err = NewApiServer(app.log, cfg.Server.Address, cfg.Server.Port, cfg.Server.AllowOrigin, cfg.Server.CertFile, cfg.Server.KeyFile) + var err error + apiServer, err = daemon.NewApiServer(logEntry, cfg.Server.Address, cfg.Server.Port, cfg.Server.AllowOrigin, cfg.Server.CertFile, cfg.Server.KeyFile) if err != nil { - log.WithError(err).Fatal("failed creating api server") + logger.WithError(err).Fatal("failed creating api server") } - } else { - app.server, _ = NewStubApiServer(app.log) } - // create mpris server if needed + var mediaPlayer mpris.Server if cfg.MprisEnabled { - app.mpris, err = mpris.NewServer(app.log) + var err error + mediaPlayer, err = mpris.NewServer(logEntry) if err != nil { - log.WithError(err).Fatal("failed creating mpris server") + logger.WithError(err).Fatal("failed creating mpris server") } - } else { - app.mpris = mpris.DummyServer{} } - ctx := context.TODO() + app, err := daemon.New(&daemon.Options{ + Logger: logEntry, + Config: cfg.toDaemonConfig(), + StateStore: store, + APIServer: apiServer, + MediaPlayer: mediaPlayer, + }) + if err != nil { + logger.WithError(err).Fatal("failed creating app") + } + defer func() { _ = app.Close() }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() - switch cfg.Credentials.Type { - case "zeroconf": - // ensure zeroconf is enabled - app.cfg.ZeroconfEnabled = true - if err := app.Zeroconf(ctx); err != nil { - log.WithError(err).Fatal("failed running zeroconf") - } - case "interactive": - if err := app.Interactive(ctx, cfg.Credentials.Interactive.CallbackPort); err != nil { - log.WithError(err).Fatal("failed running with interactive auth") - } - case "spotify_token": - if err := app.SpotifyToken(ctx, cfg.Credentials.SpotifyToken.Username, cfg.Credentials.SpotifyToken.AccessToken); err != nil { - log.WithError(err).Fatal("failed running with username and spotify token") - } - default: - log.Fatalf("unknown credentials: %s", cfg.Credentials.Type) + if err := app.Run(ctx); err != nil && !errors.Is(err, context.Canceled) { + logger.WithError(err).Fatal("daemon exited with error") } } diff --git a/cmd/daemon/api_server.go b/daemon/api_server.go similarity index 96% rename from cmd/daemon/api_server.go rename to daemon/api_server.go index 0c6ad6c7..017268a1 100644 --- a/cmd/daemon/api_server.go +++ b/daemon/api_server.go @@ -1,4 +1,4 @@ -package main +package daemon import ( "context" @@ -25,6 +25,7 @@ const timeout = 10 * time.Second type ApiServer interface { Emit(ev *ApiEvent) Receive() <-chan ApiRequest + Close() error } type ConcreteApiServer struct { @@ -104,6 +105,22 @@ func (r *ApiRequest) Reply(data any, err error) { r.resp <- apiResponse{data, err} } +// NewApiRequest builds an ApiRequest pre-wired with a reply channel, plus a +// wait function that blocks until the daemon calls Reply (or ctx is done). +func NewApiRequest(t ApiRequestType, data any) (req ApiRequest, wait func(context.Context) (any, error)) { + ch := make(chan apiResponse, 1) + req = ApiRequest{Type: t, Data: data, resp: ch} + wait = func(ctx context.Context) (any, error) { + select { + case r := <-ch: + return r.data, r.err + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return +} + type ApiRequestDataSeek struct { Position int64 `json:"position"` Relative bool `json:"relative"` @@ -199,9 +216,9 @@ func (p *AppPlayer) newApiResponseStatusTrack(media *librespot.Media, position i artists = append(artists, *a.Name) } - albumCoverId := getBestImageIdForSize(track.Album.Cover, p.app.cfg.Server.ImageSize) + albumCoverId := getBestImageIdForSize(track.Album.Cover, p.app.cfg.ImageSize) if albumCoverId == nil && track.Album.CoverGroup != nil { - albumCoverId = getBestImageIdForSize(track.Album.CoverGroup.Image, p.app.cfg.Server.ImageSize) + albumCoverId = getBestImageIdForSize(track.Album.CoverGroup.Image, p.app.cfg.ImageSize) } return &ApiResponseStatusTrack{ @@ -219,7 +236,7 @@ func (p *AppPlayer) newApiResponseStatusTrack(media *librespot.Media, position i } else { episode := media.Episode() - albumCoverId := getBestImageIdForSize(episode.CoverImage.Image, p.app.cfg.Server.ImageSize) + albumCoverId := getBestImageIdForSize(episode.CoverImage.Image, p.app.cfg.ImageSize) return &ApiResponseStatusTrack{ Uri: librespot.SpotifyIdFromGid(librespot.SpotifyIdTypeEpisode, episode.Gid).Uri(), @@ -355,6 +372,10 @@ func (s *StubApiServer) Receive() <-chan ApiRequest { return make(<-chan ApiRequest) } +func (s *StubApiServer) Close() error { + return nil +} + func (s *ConcreteApiServer) handleRequest(req ApiRequest, w http.ResponseWriter) { req.resp = make(chan apiResponse, 1) s.requests <- req @@ -680,7 +701,7 @@ func (s *ConcreteApiServer) serve() { return } else if err != nil { s.log.WithError(err).Error("failed serving api") - s.Close() + _ = s.Close() } } @@ -705,7 +726,7 @@ func (s *ConcreteApiServer) Receive() <-chan ApiRequest { return s.requests } -func (s *ConcreteApiServer) Close() { +func (s *ConcreteApiServer) Close() error { s.close = true // close all websocket clients @@ -717,4 +738,5 @@ func (s *ConcreteApiServer) Close() { // close the listener _ = s.listener.Close() + return nil } diff --git a/daemon/app.go b/daemon/app.go new file mode 100644 index 00000000..ca210080 --- /dev/null +++ b/daemon/app.go @@ -0,0 +1,420 @@ +package daemon + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "math" + "net/http" + "strings" + "time" + + librespot "github.com/devgianlu/go-librespot" + "github.com/devgianlu/go-librespot/apresolve" + "github.com/devgianlu/go-librespot/mpris" + "github.com/devgianlu/go-librespot/player" + "github.com/devgianlu/go-librespot/playplay" + devicespb "github.com/devgianlu/go-librespot/proto/spotify/connectstate/devices" + "github.com/devgianlu/go-librespot/session" + "github.com/devgianlu/go-librespot/zeroconf" + "golang.org/x/exp/rand" +) + +type App struct { + log librespot.Logger + cfg *Config + + stateStore StateStore + + client *http.Client + + resolver *apresolve.ApResolver + + deviceId string + deviceType devicespb.DeviceType + clientToken string + state *librespot.AppState + + server ApiServer + mpris mpris.Server + logoutCh chan *AppPlayer + + closed bool +} + +func parseDeviceType(val string) (devicespb.DeviceType, error) { + valEnum, ok := devicespb.DeviceType_value[strings.ToUpper(val)] + if !ok { + return 0, fmt.Errorf("invalid device type: %s", val) + } + + return devicespb.DeviceType(valEnum), nil +} + +func New(opts *Options) (*App, error) { + if opts == nil { + return nil, errors.New("daemon: Options is required") + } + if opts.Logger == nil { + return nil, errors.New("daemon: Options.Logger is required") + } + if opts.Config == nil { + return nil, errors.New("daemon: Options.Config is required") + } + if opts.StateStore == nil { + return nil, errors.New("daemon: Options.StateStore is required") + } + + app := &App{ + log: opts.Logger, + cfg: opts.Config, + stateStore: opts.StateStore, + logoutCh: make(chan *AppPlayer), + client: &http.Client{Timeout: 30 * time.Second}, + } + + var err error + app.deviceType, err = parseDeviceType(app.cfg.DeviceType) + if err != nil { + return nil, err + } + + app.state, err = opts.StateStore.Load() + if err != nil { + return nil, fmt.Errorf("loading state: %w", err) + } + if app.state == nil { + app.state = &librespot.AppState{} + } + + app.resolver = apresolve.NewApResolver(app.log, app.client) + + if app.cfg.DeviceId != "" { + app.deviceId = app.cfg.DeviceId + } else if app.state.DeviceId != "" { + app.deviceId = app.state.DeviceId + } else { + deviceIdBytes := make([]byte, 20) + _, _ = rand.Read(deviceIdBytes) + app.deviceId = hex.EncodeToString(deviceIdBytes) + app.log.Infof("generated new device id: %s", app.deviceId) + + app.state.DeviceId = app.deviceId + if err := app.persistState(); err != nil { + return nil, err + } + } + + if app.cfg.ClientToken != "" { + app.clientToken = app.cfg.ClientToken + } + + if app.cfg.FlacEnabled && !playplay.Plugin.IsSupported() { + return nil, fmt.Errorf("FLAC playback requires a PlapPlay implementation") + } + + if opts.APIServer != nil { + app.server = opts.APIServer + } else { + app.server, _ = NewStubApiServer(app.log) + } + + if opts.MediaPlayer != nil { + app.mpris = opts.MediaPlayer + } else { + app.mpris = mpris.DummyServer{} + } + + return app, nil +} + +// Run starts the daemon. It blocks until ctx is cancelled or an unrecoverable +// error occurs. The credential type configured in cfg.Credentials.Type +// determines which login flow is used. +func (app *App) Run(ctx context.Context) error { + switch app.cfg.Credentials.Type { + case "zeroconf": + // Zeroconf mode unconditionally needs zeroconf to be enabled. + app.cfg.ZeroconfEnabled = true + return app.runZeroconf(ctx) + case "interactive": + return app.runInteractive(ctx, app.cfg.Credentials.Interactive.CallbackPort) + case "spotify_token": + return app.runSpotifyToken(ctx, app.cfg.Credentials.SpotifyToken.Username, app.cfg.Credentials.SpotifyToken.AccessToken) + default: + return fmt.Errorf("unknown credentials: %s", app.cfg.Credentials.Type) + } +} + +// Close releases resources held by the daemon. It is safe to call more than once. +func (app *App) Close() error { + if app.closed { + return nil + } + app.closed = true + + var firstErr error + if app.server != nil { + if err := app.server.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + if app.mpris != nil { + if err := app.mpris.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func (app *App) persistState() error { + if err := app.stateStore.Save(app.state); err != nil { + return fmt.Errorf("persisting state: %w", err) + } + return nil +} + +func (app *App) newAppPlayer(ctx context.Context, creds any) (_ *AppPlayer, err error) { + appPlayer := &AppPlayer{ + app: app, + stop: make(chan struct{}, 1), + logout: app.logoutCh, + countryCode: new(string), + volumeUpdate: make(chan float32, 1), + playbackReadyCh: make(chan struct{}), + } + + appPlayer.prefetchTimer = time.NewTimer(math.MaxInt64) + appPlayer.prefetchTimer.Stop() + + if appPlayer.sess, err = session.NewSessionFromOptions(ctx, &session.Options{ + Log: app.log, + DeviceType: app.deviceType, + DeviceId: app.deviceId, + ClientToken: app.clientToken, + Resolver: app.resolver, + Client: app.client, + AppState: app.state, + Credentials: creds, + }); err != nil { + return nil, err + } + + appPlayer.initState() + + if appPlayer.player, err = player.NewPlayer(&player.Options{ + Spclient: appPlayer.sess.Spclient(), + AudioKey: appPlayer.sess.AudioKey(), + Events: appPlayer.sess.Events(), + Log: app.log, + + FlacEnabled: app.cfg.FlacEnabled, + + NormalisationEnabled: !app.cfg.NormalisationDisabled, + NormalisationUseAlbumGain: app.cfg.NormalisationUseAlbumGain, + NormalisationPregain: app.cfg.NormalisationPregain, + + CountryCode: appPlayer.countryCode, + + AudioBackend: app.cfg.AudioBackend, + AudioBackendRuntimeSocket: app.cfg.AudioBackendRuntimeSocket, + AudioDevice: app.cfg.AudioDevice, + MixerDevice: app.cfg.MixerDevice, + MixerControlName: app.cfg.MixerControlName, + + AudioBufferTime: app.cfg.AudioBufferTime, + AudioPeriodCount: app.cfg.AudioPeriodCount, + + ExternalVolume: app.cfg.ExternalVolume, + VolumeUpdate: appPlayer.volumeUpdate, + + AudioOutputPipe: app.cfg.AudioOutputPipe, + AudioOutputPipeFormat: app.cfg.AudioOutputPipeFormat, + }, + ); err != nil { + return nil, fmt.Errorf("failed initializing player: %w", err) + } + + return appPlayer, nil +} + +func (app *App) runZeroconf(ctx context.Context) error { + return app.withAppPlayer(ctx, func(ctx context.Context) (*AppPlayer, error) { + if app.cfg.Credentials.Zeroconf.PersistCredentials && len(app.state.Credentials.Data) > 0 { + app.log.WithField("username", librespot.ObfuscateUsername(app.state.Credentials.Username)). + Infof("loading previously persisted zeroconf credentials") + return app.newAppPlayer(ctx, session.StoredCredentials{ + Username: app.state.Credentials.Username, + Data: app.state.Credentials.Data, + }) + } + + return nil, nil + }) +} + +func (app *App) runSpotifyToken(ctx context.Context, username, token string) error { + return app.withCredentials(ctx, session.SpotifyTokenCredentials{Username: username, Token: token}) +} + +func (app *App) runInteractive(ctx context.Context, callbackPort int) error { + return app.withCredentials(ctx, session.InteractiveCredentials{CallbackPort: callbackPort}) +} + +func (app *App) withCredentials(ctx context.Context, creds any) (err error) { + return app.withAppPlayer(ctx, func(ctx context.Context) (*AppPlayer, error) { + if len(app.state.Credentials.Data) > 0 { + return app.newAppPlayer(ctx, session.StoredCredentials{ + Username: app.state.Credentials.Username, + Data: app.state.Credentials.Data, + }) + } else { + appPlayer, err := app.newAppPlayer(ctx, creds) + if err != nil { + return nil, err + } + + app.state.Credentials.Username = appPlayer.sess.Username() + app.state.Credentials.Data = appPlayer.sess.StoredCredentials() + + if err = app.persistState(); err != nil { + return nil, err + } + + app.log.WithField("username", librespot.ObfuscateUsername(appPlayer.sess.Username())). + Debugf("stored credentials") + return appPlayer, nil + } + }) +} + +func (app *App) withAppPlayer(ctx context.Context, appPlayerFunc func(context.Context) (*AppPlayer, error)) (err error) { + if !app.cfg.ZeroconfEnabled { + appPlayer, err := appPlayerFunc(ctx) + if err != nil { + return err + } else if appPlayer == nil { + panic("zeroconf is disabled and no credentials are present") + } + + appPlayer.Run(ctx, app.server.Receive(), app.mpris.Receive()) + return nil + } + + if err := app.resolver.FetchAll(ctx); err != nil { + return fmt.Errorf("failed getting endpoints from resolver: %w", err) + } + + z, err := zeroconf.NewZeroconf(app.log, app.cfg.ZeroconfPort, app.cfg.DeviceName, app.deviceId, app.deviceType, app.cfg.ZeroconfInterfacesToAdvertise, app.cfg.ZeroconfBackend == "avahi") + if err != nil { + return fmt.Errorf("failed initializing zeroconf: %w", err) + } + + var apiCh chan ApiRequest + + currentPlayer, err := appPlayerFunc(ctx) + if err != nil { + return err + } + + if currentPlayer != nil { + app.log.WithField("username", librespot.ObfuscateUsername(currentPlayer.sess.Username())). + Debugf("initializing zeroconf session") + + apiCh = make(chan ApiRequest) + go currentPlayer.Run(ctx, apiCh, app.mpris.Receive()) + + z.SetCurrentUser(currentPlayer.sess.Username()) + } + + go func() { + for { + select { + case req := <-app.server.Receive(): + if currentPlayer == nil { + if req.Type == ApiRequestTypeRoot { + req.Reply(&ApiResponseRoot{}, nil) + } else { + req.Reply(nil, ErrNoSession) + } + break + } + + apiCh <- req + } + } + }() + + go func() { + for { + select { + case p := <-app.logoutCh: + if p != currentPlayer { + continue + } + + currentPlayer.Close() + currentPlayer = nil + + close(apiCh) + + newAppPlayer, err := appPlayerFunc(ctx) + if err != nil { + app.log.WithError(err).Errorf("failed restoring session after logout") + + z.SetCurrentUser("") + } else if newAppPlayer == nil { + z.SetCurrentUser("") + } else { + apiCh = make(chan ApiRequest) + currentPlayer = newAppPlayer + + go newAppPlayer.Run(ctx, apiCh, app.mpris.Receive()) + + z.SetCurrentUser(newAppPlayer.sess.Username()) + + app.log.WithField("username", librespot.ObfuscateUsername(currentPlayer.sess.Username())). + Debugf("restored session after logout") + } + } + } + }() + + return z.Serve(func(req zeroconf.NewUserRequest) bool { + if currentPlayer != nil { + currentPlayer.Close() + currentPlayer = nil + + close(apiCh) + } + + newAppPlayer, err := app.newAppPlayer(ctx, session.BlobCredentials{ + Username: req.Username, + Blob: req.AuthBlob, + }) + if err != nil { + app.log.WithError(err).WithField("username", librespot.ObfuscateUsername(req.Username)). + Errorf("failed creating new session from %s", req.DeviceName) + return false + } + + apiCh = make(chan ApiRequest) + currentPlayer = newAppPlayer + + if app.cfg.Credentials.Zeroconf.PersistCredentials { + app.state.Credentials.Username = newAppPlayer.sess.Username() + app.state.Credentials.Data = newAppPlayer.sess.StoredCredentials() + + if err := app.persistState(); err != nil { + app.log.WithError(err).Errorf("failed persisting zeroconf credentials") + } + + app.log.WithField("username", librespot.ObfuscateUsername(newAppPlayer.sess.Username())). + Debugf("persisted zeroconf credentials") + } + + go newAppPlayer.Run(ctx, apiCh, app.mpris.Receive()) + return true + }) +} diff --git a/daemon/config.go b/daemon/config.go new file mode 100644 index 00000000..d64f19f1 --- /dev/null +++ b/daemon/config.go @@ -0,0 +1,62 @@ +package daemon + +// Config carries the runtime configuration for a daemon instance. +type Config struct { + DeviceId string + DeviceName string + DeviceType string + ClientToken string + + AudioBackend string + AudioBackendRuntimeSocket string + AudioDevice string + MixerDevice string + MixerControlName string + AudioBufferTime int + AudioPeriodCount int + AudioOutputPipe string + AudioOutputPipeFormat string + + Bitrate int + VolumeSteps uint32 + InitialVolume uint32 + IgnoreLastVolume bool + NormalisationDisabled bool + NormalisationUseAlbumGain bool + NormalisationPregain float32 + ExternalVolume bool + DisableAutoplay bool + + ZeroconfEnabled bool + ZeroconfPort int + ZeroconfBackend string + ZeroconfInterfacesToAdvertise []string + + FlacEnabled bool + + // ImageSize selects which cover-art image variant the API server returns: + // "default", "small", "medium", "large", "xlarge". + ImageSize string + + Credentials CredentialsConfig +} + +type CredentialsConfig struct { + Type string + Interactive InteractiveCredentials + SpotifyToken SpotifyTokenCredentials + Zeroconf ZeroconfCredentials +} + +type InteractiveCredentials struct { + CallbackPort int +} + +type SpotifyTokenCredentials struct { + Username string + AccessToken string +} + +type ZeroconfCredentials struct { + PersistCredentials bool +} diff --git a/cmd/daemon/controls.go b/daemon/controls.go similarity index 99% rename from cmd/daemon/controls.go rename to daemon/controls.go index 4b9600e6..defc7c10 100644 --- a/cmd/daemon/controls.go +++ b/daemon/controls.go @@ -1,4 +1,4 @@ -package main +package daemon import ( "context" @@ -731,7 +731,7 @@ func (p *AppPlayer) updateVolume(newVal uint32) { // Save the volume to the state p.app.state.LastVolume = &newVal - if err := p.app.state.Write(); err != nil { + if err := p.app.persistState(); err != nil { p.app.log.WithError(err).Error("failed writing state after volume change") } diff --git a/daemon/options.go b/daemon/options.go new file mode 100644 index 00000000..e9a2ecec --- /dev/null +++ b/daemon/options.go @@ -0,0 +1,16 @@ +package daemon + +import ( + librespot "github.com/devgianlu/go-librespot" + "github.com/devgianlu/go-librespot/mpris" +) + +// Options bundles the dependencies a daemon needs at construction time. +type Options struct { + Logger librespot.Logger + Config *Config + StateStore StateStore + + APIServer ApiServer + MediaPlayer mpris.Server +} diff --git a/cmd/daemon/player.go b/daemon/player.go similarity index 99% rename from cmd/daemon/player.go rename to daemon/player.go index 20b8faf7..526691bb 100644 --- a/cmd/daemon/player.go +++ b/daemon/player.go @@ -1,4 +1,4 @@ -package main +package daemon import ( "bytes" diff --git a/cmd/daemon/state.go b/daemon/player_state.go similarity index 99% rename from cmd/daemon/state.go rename to daemon/player_state.go index 2faa2ff5..4221671a 100644 --- a/cmd/daemon/state.go +++ b/daemon/player_state.go @@ -1,4 +1,4 @@ -package main +package daemon import ( "context" diff --git a/cmd/daemon/product_info.go b/daemon/product_info.go similarity index 97% rename from cmd/daemon/product_info.go rename to daemon/product_info.go index bceeaa83..99cc3b6b 100644 --- a/cmd/daemon/product_info.go +++ b/daemon/product_info.go @@ -1,4 +1,4 @@ -package main +package daemon import ( "encoding/hex" diff --git a/daemon/state_store.go b/daemon/state_store.go new file mode 100644 index 00000000..2a8e8a08 --- /dev/null +++ b/daemon/state_store.go @@ -0,0 +1,11 @@ +package daemon + +import ( + librespot "github.com/devgianlu/go-librespot" +) + +// StateStore abstracts how a daemon persists its long-lived state. +type StateStore interface { + Load() (*librespot.AppState, error) + Save(*librespot.AppState) error +} diff --git a/state.go b/state.go index 62aa6a1f..abbcd6e8 100644 --- a/state.go +++ b/state.go @@ -2,18 +2,12 @@ package go_librespot import ( "encoding/json" - "fmt" - "os" - "path/filepath" "sync" ) type AppState struct { sync.Mutex - log Logger - path string - DeviceId string `json:"device_id"` EventManager json.RawMessage `json:"event_manager"` Credentials struct { @@ -22,59 +16,3 @@ type AppState struct { } `json:"credentials"` LastVolume *uint32 `json:"last_volume"` } - -func (s *AppState) SetLogger(log Logger) { - s.log = log -} - -func (s *AppState) Read(configDir string) error { - s.path = filepath.Join(configDir, "state.json") - - if content, err := os.ReadFile(s.path); err == nil { - if err := json.Unmarshal(content, &s); err != nil { - return fmt.Errorf("failed unmarshalling state file: %w", err) - } - s.log.Debugf("app state loaded") - } else { - s.log.Debugf("no app state found") - } - - // Read credentials (old configuration, in credentials.json). - if s.Credentials.Username == "" { - if content, err := os.ReadFile(filepath.Join(configDir, "credentials.json")); err == nil { - if err := json.Unmarshal(content, &s.Credentials); err != nil { - return fmt.Errorf("failed unmarshalling stored credentials file: %w", err) - } - - s.log.WithField("username", ObfuscateUsername(s.Credentials.Username)). - Debugf("stored credentials found") - } else { - s.log.Debugf("stored credentials not found") - } - } - - return nil -} - -func (s *AppState) Write() error { - s.Lock() - defer s.Unlock() - - // Create a temporary file, and overwrite the old file. - // This is a way to atomically replace files. - // The file is created with mode 0o600 so we don't need to change the mode. - tmpFile, err := os.CreateTemp(filepath.Dir(s.path), filepath.Base(s.path)+".*.tmp") - if err != nil { - return fmt.Errorf("failed creating temporary file for app state: %w", err) - } - - if err := json.NewEncoder(tmpFile).Encode(&s); err != nil { - return fmt.Errorf("failed writing marshalled app state: %w", err) - } - - if err := os.Rename(tmpFile.Name(), s.path); err != nil { - return fmt.Errorf("failed replacing app state file: %w", err) - } - - return nil -}