diff --git a/cmd/daemon/api_server.go b/cmd/daemon/api_server.go index 0c6ad6c..ed341f5 100644 --- a/cmd/daemon/api_server.go +++ b/cmd/daemon/api_server.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/url" + "strconv" "strings" "sync" "time" @@ -72,6 +73,7 @@ const ( ApiRequestTypeSetShufflingContext ApiRequestType = "shuffling_context" ApiRequestTypeAddToQueue ApiRequestType = "add_to_queue" ApiRequestTypeToken ApiRequestType = "token" + ApiRequestTypeResolveTracks ApiRequestType = "resolve_tracks" ) type ApiEventType string @@ -130,6 +132,12 @@ type ApiRequestDataNext struct { Uri *string `json:"uri"` } +type ApiRequestDataResolveTracks struct { + Uri string `json:"uri"` + Offset int `json:"offset"` + Limit int `json:"limit"` +} + type apiResponse struct { data any err error @@ -266,6 +274,28 @@ type ApiResponseToken struct { Token string `json:"token"` } +type ApiResponseResolvedTrack struct { + Uri string `json:"uri"` + Uid string `json:"uid"` + Name string `json:"name"` + Artists []string `json:"artists"` + Img string `json:"img"` + AlbumName string `json:"album_name"` + DurationMs int `json:"duration_ms"` + AlbumUri string `json:"album_uri"` + ArtistUri string `json:"artist_uri"` + Metadata map[string]string `json:"metadata"` +} + +type ApiResponseResolveTracks struct { + Uri string `json:"uri"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Total int `json:"total"` + HasNext bool `json:"has_next"` + Tracks []ApiResponseResolvedTrack `json:"tracks"` +} + type ApiEvent struct { Type ApiEventType `json:"type"` Data any `json:"data"` @@ -618,6 +648,44 @@ func (s *ConcreteApiServer) serve() { s.handleRequest(ApiRequest{Type: ApiRequestTypeToken}, w) }) + m.HandleFunc("/resolver/tracks", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + query := r.URL.Query() + uri := strings.TrimSpace(query.Get("uri")) + if len(uri) == 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + + offset := 0 + if rawOffset := strings.TrimSpace(query.Get("offset")); len(rawOffset) > 0 { + parsed, err := strconv.Atoi(rawOffset) + if err != nil || parsed < 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + offset = parsed + } + + limit := 50 + if rawLimit := strings.TrimSpace(query.Get("limit")); len(rawLimit) > 0 { + parsed, err := strconv.Atoi(rawLimit) + if err != nil || parsed <= 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + limit = parsed + } + if limit > 200 { + limit = 200 + } + + s.handleRequest(ApiRequest{Type: ApiRequestTypeResolveTracks, Data: ApiRequestDataResolveTracks{Uri: uri, Offset: offset, Limit: limit}}, w) + }) m.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { opts := &websocket.AcceptOptions{} if len(s.allowOrigin) > 0 { diff --git a/cmd/daemon/player.go b/cmd/daemon/player.go index 20b8faf..f43afb9 100644 --- a/cmd/daemon/player.go +++ b/cmd/daemon/player.go @@ -24,6 +24,8 @@ import ( "github.com/devgianlu/go-librespot/dealer" "github.com/devgianlu/go-librespot/player" connectpb "github.com/devgianlu/go-librespot/proto/spotify/connectstate" + extmetadatapb "github.com/devgianlu/go-librespot/proto/spotify/extendedmetadata" + metadatapb "github.com/devgianlu/go-librespot/proto/spotify/metadata" "github.com/devgianlu/go-librespot/session" "github.com/devgianlu/go-librespot/tracks" ) @@ -590,11 +592,195 @@ func (p *AppPlayer) handleApiRequest(ctx context.Context, req ApiRequest) (any, return &ApiResponseToken{ Token: accessToken, }, nil + case ApiRequestTypeResolveTracks: + data := req.Data.(ApiRequestDataResolveTracks) + + spotID, err := librespot.SpotifyIdFromUri(data.Uri) + if err != nil || spotID.Type() != librespot.SpotifyIdTypePlaylist { + return nil, ErrBadRequest + } + + spotCtx, err := p.sess.Spclient().ContextResolve(ctx, data.Uri) + if err != nil { + return nil, fmt.Errorf("failed resolving context: %w", err) + } + + ctxTracks, err := tracks.NewTrackListFromContext(ctx, p.app.log, p.sess.Spclient(), spotCtx) + if err != nil { + return nil, fmt.Errorf("failed creating track list from context: %w", err) + } + + allTracks := ctxTracks.AllTracks(ctx) + total := len(allTracks) + offset := data.Offset + if offset > total { + offset = total + } + end := offset + data.Limit + if end > total { + end = total + } + + resolvedTracks := make([]ApiResponseResolvedTrack, 0, end-offset) + for _, tr := range allTracks[offset:end] { + mapped := mapResolvedTrack(tr) + if mapped.Name == "" || len(mapped.Artists) == 0 || mapped.Img == "" { + enrichResolvedTrack(ctx, p, &mapped) + } + resolvedTracks = append(resolvedTracks, mapped) + } + + return &ApiResponseResolveTracks{ + Uri: data.Uri, + Offset: offset, + Limit: data.Limit, + Total: total, + HasNext: end < total, + Tracks: resolvedTracks, + }, nil default: return nil, fmt.Errorf("unknown request type: %s", req.Type) } } +func mapResolvedTrack(track *connectpb.ProvidedTrack) ApiResponseResolvedTrack { + metadata := cloneMetadata(track.Metadata) + artistURI := track.ArtistUri + if artistURI == "" { + artistURI = firstNonEmpty(metadata, "artist_uri") + } + albumURI := track.AlbumUri + if albumURI == "" { + albumURI = firstNonEmpty(metadata, "album_uri") + } + + return ApiResponseResolvedTrack{ + Uri: track.Uri, + Uid: track.Uid, + Name: firstNonEmpty(metadata, "name", "title", "track_name"), + Artists: parseArtists(metadata), + Img: firstNonEmpty(metadata, "image_url", "img", "album_cover_url"), + AlbumName: firstNonEmpty(metadata, "album_name", "album_title"), + DurationMs: parseDurationMs(metadata), + AlbumUri: albumURI, + ArtistUri: artistURI, + Metadata: metadata, + } +} + +func enrichResolvedTrack(ctx context.Context, p *AppPlayer, track *ApiResponseResolvedTrack) { + spotID, err := librespot.SpotifyIdFromUri(track.Uri) + if err != nil || spotID.Type() != librespot.SpotifyIdTypeTrack { + return + } + + var trackMeta metadatapb.Track + if err := p.sess.Spclient().ExtendedMetadataSimple(ctx, *spotID, extmetadatapb.ExtensionKind_TRACK_V4, &trackMeta); err != nil { + return + } + + if track.Name == "" { + track.Name = trackMeta.GetName() + } + + if len(track.Artists) == 0 { + artists := make([]string, 0, len(trackMeta.GetArtist())) + for _, artist := range trackMeta.GetArtist() { + if name := strings.TrimSpace(artist.GetName()); name != "" { + artists = append(artists, name) + } + } + if len(artists) > 0 { + track.Artists = artists + } + } + + if track.AlbumName == "" && trackMeta.GetAlbum() != nil { + track.AlbumName = trackMeta.GetAlbum().GetName() + } + + if track.DurationMs == 0 { + track.DurationMs = int(trackMeta.GetDuration()) + } + + if track.Img == "" && p.prodInfo != nil && trackMeta.GetAlbum() != nil { + album := trackMeta.GetAlbum() + coverId := getBestImageIdForSize(album.GetCover(), p.app.cfg.Server.ImageSize) + if coverId == nil && album.GetCoverGroup() != nil { + coverId = getBestImageIdForSize(album.GetCoverGroup().GetImage(), p.app.cfg.Server.ImageSize) + } + if img := p.prodInfo.ImageUrl(coverId); img != nil { + track.Img = *img + } + } +} + +func cloneMetadata(metadata map[string]string) map[string]string { + if metadata == nil { + return map[string]string{} + } + + out := make(map[string]string, len(metadata)) + for k, v := range metadata { + out[k] = v + } + return out +} + +func firstNonEmpty(metadata map[string]string, keys ...string) string { + for _, key := range keys { + if value := strings.TrimSpace(metadata[key]); value != "" { + return value + } + } + return "" +} + +func parseArtists(metadata map[string]string) []string { + candidate := firstNonEmpty(metadata, "artist_names", "artists", "artist_name") + if candidate == "" { + return nil + } + + var asList []string + if err := json.Unmarshal([]byte(candidate), &asList); err == nil { + artists := make([]string, 0, len(asList)) + for _, name := range asList { + name = strings.TrimSpace(name) + if name != "" { + artists = append(artists, name) + } + } + if len(artists) > 0 { + return artists + } + } + + parts := strings.Split(candidate, ",") + artists := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + artists = append(artists, part) + } + } + if len(artists) == 0 { + return nil + } + return artists +} + +func parseDurationMs(metadata map[string]string) int { + for _, key := range []string{"duration_ms", "duration"} { + if raw := strings.TrimSpace(metadata[key]); raw != "" { + if value, err := strconv.Atoi(raw); err == nil && value >= 0 { + return value + } + } + } + return 0 +} + func pointer[T any](d T) *T { return &d } diff --git a/go.mod b/go.mod index 597c1f0..c73eb2f 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 github.com/coder/websocket v1.8.14 github.com/devgianlu/shannon v0.0.0-20230613115856-82ec90b7fa7e - github.com/godbus/dbus/v5 v5.2.0 + github.com/godbus/dbus/v5 v5.2.2 github.com/gofrs/flock v0.13.0 github.com/grandcat/zeroconf v1.0.0 github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53 @@ -33,6 +33,7 @@ require ( require ( github.com/cenkalti/backoff v2.2.1+incompatible // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -43,6 +44,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/stretchr/objx v0.5.3 // indirect + github.com/zalando/go-keyring v0.2.7 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index 98d90eb..c106e24 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -15,6 +17,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -66,6 +70,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xlab/vorbis-go v0.0.0-20210911202351-b5b85f1ec645 h1:lYg/+vV/Fd5WM1+Ptg54Am3y4mDXaMSrT+mKUHV5uVc= github.com/xlab/vorbis-go v0.0.0-20210911202351-b5b85f1ec645/go.mod h1:AMqfx3jFwPqem3u8mF2lsRodZs30jG/Mag5HZ3mB3sA= +github.com/zalando/go-keyring v0.2.7 h1:YbqBw40+g4g69UNk4WsRM/fV9YErfVWwozE2+7Bn+7g= +github.com/zalando/go-keyring v0.2.7/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/state.go b/state.go index 62aa6a1..36aedbf 100644 --- a/state.go +++ b/state.go @@ -3,6 +3,7 @@ package go_librespot import ( "encoding/json" "fmt" + "github.com/zalando/go-keyring" "os" "path/filepath" "sync" @@ -51,11 +52,16 @@ func (s *AppState) Read(configDir string) error { } else { s.log.Debugf("stored credentials not found") } + + // Credentials might be in the OS keyring. + s.getCredsFromKeyring() + } return nil } + func (s *AppState) Write() error { s.Lock() defer s.Unlock() @@ -67,9 +73,13 @@ func (s *AppState) Write() error { 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 := s.writeCredsToKeyring(); err != nil { + if err := json.NewEncoder(tmpFile).Encode(s); err != nil { + return fmt.Errorf("failed writing marshalled app state: %w", err) + } + } + if err := s.persistStateWithoutCredentials(tmpFile); err != nil { + return err } if err := os.Rename(tmpFile.Name(), s.path); err != nil { @@ -78,3 +88,42 @@ func (s *AppState) Write() error { return nil } + +func (s *AppState) persistStateWithoutCredentials(tmpFile *os.File) error { + persistedState := struct { + DeviceId string `json:"device_id"` + EventManager json.RawMessage `json:"event_manager"` + LastVolume *uint32 `json:"last_volume"` + }{ + DeviceId: s.DeviceId, + EventManager: s.EventManager, + LastVolume: s.LastVolume, + } + + if err := json.NewEncoder(tmpFile).Encode(&persistedState); err != nil { + return fmt.Errorf("failed writing marshalled app state: %w", err) + } + return nil +} + +func (s *AppState) getCredsFromKeyring() { + storedCredsRaw, err := keyring.Get("librespot", "credentials") + if err != nil { + return + } + if err := json.Unmarshal([]byte(storedCredsRaw), &s.Credentials); err != nil { + return + } +} + +func (s *AppState) writeCredsToKeyring() error { + creds, err := json.Marshal(s.Credentials) + if err != nil { + return err + } + err = keyring.Set("librespot", "credentials", string(creds)) + if err != nil { + return err + } + return nil +}