WIP: use the listenbrainz radio api #54
1
go.mod
1
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
|
||||
|
4
go.sum
4
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=
|
||||
|
@ -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
|
||||
|
180
src/listenbrainz/listenbrainz.go
Normal file
180
src/listenbrainz/listenbrainz.go
Normal file
@ -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)
|
||||
}
|
79
src/listenbrainz/radio.go
Normal file
79
src/listenbrainz/radio.go
Normal file
@ -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"`
|
||||
}
|
Loading…
Reference in New Issue
Block a user