diff --git a/go.mod b/go.mod index 16cf623..425b6b9 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.1.2 github.com/charmbracelet/lipgloss v0.13.1 + github.com/go-resty/resty/v2 v2.16.2 github.com/lmittmann/tint v1.0.5 github.com/rivo/tview v0.0.0-20241016194538-c5e4fb24af13 github.com/urfave/cli/v3 v3.0.0-alpha9.1 diff --git a/go.sum b/go.sum index cd4fc5d..95af9ab 100644 --- a/go.sum +++ b/go.sum @@ -94,6 +94,8 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-resty/resty/v2 v2.16.2 h1:CpRqTjIzq/rweXUt9+GxzzQdlkqMdt8Lm/fuK/CAbAg= +github.com/go-resty/resty/v2 v2.16.2/go.mod h1:0fHAoK7JoBy/Ch36N8VFeMsK7xQOHhvWaC3iOktwmIU= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -418,6 +420,8 @@ golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/src/components/commands/radio.go b/src/components/commands/radio.go index 6c90d4e..5361104 100644 --- a/src/components/commands/radio.go +++ b/src/components/commands/radio.go @@ -18,18 +18,18 @@ import ( func (c *Commander) Radio() error { currentSong, err := c.Client().PlayerCurrentlyPlaying(c.Context) if err != nil { - return err + return fmt.Errorf("failed to get current song: %w", err) } if currentSong.Item != nil { return c.RadioGivenSong(currentSong.Item.SimpleTrack, currentSong.Progress) } _, err = c.activateDevice() if err != nil { - return err + return fmt.Errorf("failed to activate device: %w", err) } tracks, err := c.Client().CurrentUsersTracks(c.Context, spotify.Limit(10)) if err != nil { - return err + return fmt.Errorf("failed to get current users tracks: %w", err) } return c.RadioGivenSong(tracks.Tracks[rand.Intn(len(tracks.Tracks))].SimpleTrack, 0) } @@ -178,7 +178,7 @@ func (c *Commander) RadioGivenSong(song spotify.SimpleTrack, pos spotify.Numeric } recomendations, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(99)) if err != nil { - return err + return fmt.Errorf("failed to get recommendations: %w", err) } recomendationIds := []spotify.ID{} for _, song := range recomendations.Tracks { @@ -218,25 +218,9 @@ func (c *Commander) RadioGivenSong(song spotify.SimpleTrack, pos spotify.Numeric if pos != 0 { pos = pos + spotify.Numeric(delay) } - err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{ - PlaybackContext: &radioPlaylist.URI, - PositionMs: pos, - }) + err = c.PlayRadio(radioPlaylist, int(pos)) if err != nil { - if isNoActiveError(err) { - deviceID, err := c.activateDevice() - if err != nil { - return err - } - err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{ - PlaybackContext: &radioPlaylist.URI, - DeviceID: &deviceID, - PositionMs: pos, - }) - if err != nil { - return err - } - } + return err } err = c.Client().Repeat(c.Context, "context") if err != nil { @@ -274,6 +258,30 @@ func (c *Commander) RadioGivenSong(song spotify.SimpleTrack, pos spotify.Numeric return nil } +func (c *Commander) PlayRadio(radioPlaylist *spotify.FullPlaylist, pos int) error { + err := c.Client().PlayOpt(c.Context, &spotify.PlayOptions{ + PlaybackContext: &radioPlaylist.URI, + PositionMs: spotify.Numeric(pos), + }) + if err != nil { + if isNoActiveError(err) { + deviceID, err := c.activateDevice() + if err != nil { + return err + } + err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{ + PlaybackContext: &radioPlaylist.URI, + DeviceID: &deviceID, + PositionMs: spotify.Numeric(pos), + }) + if err != nil { + return err + } + } + } + return nil +} + func (c *Commander) ClearRadio() error { radioPlaylist, db, err := c.GetRadioPlaylist("") if err != nil { @@ -581,23 +589,9 @@ func (c *Commander) RadioGivenList(songs []spotify.ID, name string) error { if err != nil { return err } - err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{ - PlaybackContext: &radioPlaylist.URI, - }) + err = c.PlayRadio(radioPlaylist, 0) if err != nil { - if isNoActiveError(err) { - deviceID, err := c.activateDevice() - if err != nil { - return err - } - err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{ - PlaybackContext: &radioPlaylist.URI, - DeviceID: &deviceID, - }) - if err != nil { - return err - } - } + return err } for i := 0; i < 4; i++ { id := rand.Intn(len(recomendationIds)-2) + 1 diff --git a/src/listenbrainz/listenbrainz.go b/src/listenbrainz/listenbrainz.go new file mode 100644 index 0000000..ec6acf7 --- /dev/null +++ b/src/listenbrainz/listenbrainz.go @@ -0,0 +1,180 @@ +package listenbrainz + +import ( + "context" + "fmt" + + "github.com/go-resty/resty/v2" +) + +type ListenBrainz struct { + client *resty.Client + + labs *resty.Client +} + +func NewListenBrainz( + Endpoint string, + ApiKey string, +) *ListenBrainz { + c := resty.New().SetBaseURL(Endpoint) + if ApiKey != "" { + c = c.SetHeader("Authorization", "Token "+ApiKey) + } + return &ListenBrainz{ + client: c, + labs: resty.New().SetBaseURL("https://labs.api.listenbrainz.org"), + } +} + +type RadioApiResponse struct { + Payload struct { + Feedback []string `json:"feedback"` + Jspf struct { + Playlist struct { + Annotation string `json:"annotation"` + Creator string `json:"creator"` + Extension struct { + HTTPSMusicbrainzOrgDocJspfPlaylist struct { + Public bool `json:"public"` + } `json:"https://musicbrainz.org/doc/jspf#playlist"` + } `json:"extension"` + Title string `json:"title"` + Track []ApiTrack `json:"track"` + } `json:"playlist"` + } `json:"jspf"` + } `json:"payload"` +} + +type ApiTrack struct { + Album string `json:"album"` + Creator string `json:"creator"` + Duration int `json:"duration,omitempty"` + Extension struct { + HTTPSMusicbrainzOrgDocJspfTrack struct { + ArtistIdentifiers []string `json:"artist_identifiers"` + ReleaseIdentifier string `json:"release_identifier"` + } `json:"https://musicbrainz.org/doc/jspf#track"` + } `json:"extension"` + Identifier []string `json:"identifier"` + Title string `json:"title"` +} + +func (o *ListenBrainz) RequestRadio(ctx context.Context, req *RadioRequest) (*RadioTracksResponse, error) { + var res RadioApiResponse + resp, err := o.client.R(). + SetResult(&res). + SetQueryParam("prompt", req.Prompt). + SetQueryParam("mode", req.Prompt). + Get("/1/explore/lb-radio") + if err != nil { + return nil, err + } + switch { + case resp.StatusCode() == 200: + default: + return nil, fmt.Errorf("radio request code %d: %s", resp.StatusCode(), resp.Status()) + } + tracks := res.Payload.Jspf.Playlist.Track + return &RadioTracksResponse{ + Tracks: tracks, + }, nil +} + +type RadioTracksResponse struct { + Tracks []ApiTrack +} + +type RadioRequest struct { + Prompt string + Mode string `json:"mode"` +} + +type MatchTracksParams struct { + Tracks []ApiTrack +} + +func (o *ListenBrainz) MatchTracks(ctx context.Context, params *MatchTracksParams) error { + // first try to get the mbid from the recording id + o.labs.R().Get("/") + return nil +} + +type MatchedTrack struct { + Mbid string + SpotifyId string + Strategy string +} + +type TrackMatch struct { + RecordingMbid string `json:"recording_mbid"` + ArtistName string `json:"artist_name"` + ReleaseName string `json:"release_name"` + TrackName string `json:"track_name"` + SpotifyTrackIds []string `json:"spotify_track_ids"` +} + +var ErrNoMatch = fmt.Errorf("no match") + +func (o *ListenBrainz) MatchTrack(ctx context.Context, track *ApiTrack) (*MatchedTrack, error) { + // refuse to match a track with no identifiers + if len(track.Identifier) == 0 { + return nil, fmt.Errorf("%w: no identifier", ErrNoMatch) + } + var matches []TrackMatch + // there are mbids, so try to get the first one that is valid + resp, err := o.labs.R(). + SetResult(&matches). + SetQueryParam("recording_mbid", track.Identifier[0]). + Get("/spotify-id-from-mbid/json") + if err != nil { + return nil, err + } + if resp.StatusCode() != 200 { + return nil, fmt.Errorf("labs request code %d: %s", resp.StatusCode(), resp.Status()) + } + if len(matches) == 0 { + return nil, fmt.Errorf("%w: no mbid", ErrNoMatch) + } + // for each match, see if ther eis a spotify id, and if there is, we are done! + for _, match := range matches { + if len(match.SpotifyTrackIds) == 0 { + continue + } + return &MatchedTrack{ + Mbid: match.RecordingMbid, + SpotifyId: match.SpotifyTrackIds[0], + Strategy: "exact-match", + }, nil + } + for _, match := range matches { + var submatch []TrackMatch + resp, err := o.labs.R(). + SetResult(&submatch). + SetQueryParam("artist_name", match.ArtistName). + SetQueryParam("release_name", match.ReleaseName). + SetQueryParam("track_name", match.TrackName). + Get("/spotify-id-from-track/json") + if err != nil { + return nil, err + } + if resp.StatusCode() != 200 { + return nil, fmt.Errorf("labs request code %d: %s", resp.StatusCode(), resp.Status()) + } + if len(submatch) == 0 { + return nil, fmt.Errorf("%w: no tracks found", ErrNoMatch) + } + for _, submatch := range submatch { + if len(submatch.SpotifyTrackIds) == 0 { + continue + } + return &MatchedTrack{ + Mbid: match.RecordingMbid, + SpotifyId: submatch.SpotifyTrackIds[0], + Strategy: "track-match", + }, nil + } + } + + return nil, fmt.Errorf("%w: no tracks found", ErrNoMatch) +} diff --git a/src/listenbrainz/radio.go b/src/listenbrainz/radio.go new file mode 100644 index 0000000..e977ef2 --- /dev/null +++ b/src/listenbrainz/radio.go @@ -0,0 +1,79 @@ +package listenbrainz + +import ( + "bytes" + "strconv" +) + +type RadioPromptBuilder struct { + px RadioParameters +} + +func (o *RadioPromptBuilder) Add(name string, values ...string) *RadioPromptBuilder { + o.px = append(o.px, RadioParameter{ + Name: name, + Values: values, + }) + return o +} + +func (o *RadioPromptBuilder) AddParameter(p RadioParameter) *RadioPromptBuilder { + o.px = append(o.px, p) + return o +} + +func (o *RadioPromptBuilder) AddWithCount(name string, count int, values ...string) *RadioPromptBuilder { + o.px = append(o.px, RadioParameter{ + Name: name, + Count: count, + Values: values, + }) + return o +} +func (o *RadioPromptBuilder) AddWithOption(name string, option string, values ...string) *RadioPromptBuilder { + o.px = append(o.px, RadioParameter{ + Name: name, + Option: option, + Values: values, + }) + return o +} + +func (o *RadioPromptBuilder) String() string { + val, _ := o.px.MarshalText() + return string(val) +} + +type RadioParameters []RadioParameter + +func (r RadioParameters) MarshalText() ([]byte, error) { + o := &bytes.Buffer{} + for pidx, v := range r { + o.WriteString(v.Name) + o.WriteString(":(") + for idx, vv := range v.Values { + o.WriteString(vv) + if len(v.Values) > 1 && idx != len(v.Values)-1 { + o.WriteString(",") + } + } + o.WriteString(")") + if v.Count > 0 { + o.WriteString(":" + strconv.Itoa(v.Count)) + } + if v.Option != "" { + o.WriteString(":" + v.Option) + } + if len(r) > 1 && pidx != len(r)-1 { + o.WriteString(" ") + } + } + return o.Bytes(), nil +} + +type RadioParameter struct { + Name string `json:"name"` + Values []string `json:"value"` + Count int `json:"count"` + Option string `json:"options"` +}