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/main.go b/main.go index 04fdecd..f94d713 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,9 @@ import ( "git.asdf.cafe/abs3nt/gspot/src/components/cli" "git.asdf.cafe/abs3nt/gspot/src/components/commands" "git.asdf.cafe/abs3nt/gspot/src/components/logger" + "git.asdf.cafe/abs3nt/gspot/src/components/sqldb" + "git.asdf.cafe/abs3nt/gspot/src/config" + "git.asdf.cafe/abs3nt/gspot/src/listenbrainz" "git.asdf.cafe/abs3nt/gspot/src/services" ) @@ -30,6 +33,8 @@ func main() { Context, cache.NewCache, commands.NewCommander, + sqldb.New, + listenbrainz.New, logger.NewLogger, ), fx.Invoke( @@ -52,6 +57,7 @@ func Context( log = slog.Default() } ctx, cn := context.WithCancelCause(context.Background()) + ctx = config.WithLogger(ctx, log) lc.Append(fx.Hook{ OnStop: func(ctx context.Context) error { cn(fmt.Errorf("%w: %w", context.Canceled, ErrContextShutdown)) diff --git a/src/components/cli/cli.go b/src/components/cli/cli.go index 8c5f52d..c919d33 100644 --- a/src/components/cli/cli.go +++ b/src/components/cli/cli.go @@ -285,14 +285,37 @@ func Run(c *commands.Commander, s fx.Shutdowner) { Category: "Info", }, { - Name: "radio", - Usage: "Starts a radio from the current song", + Name: "radio", + Usage: "creates a radio using a prompt", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "prompt", + Aliases: []string{"p"}, + Usage: "prompt", + }, + &cli.StringFlag{ + Name: "mode", + Aliases: []string{"m"}, + Value: "hard", + Validator: func(s string) error { + if s != "hard" && s != "easy" && s != "medium" { + return fmt.Errorf("invalid mode: %s (valid: hard, easy, medium)", s) + } + return nil + }, + Usage: "prompt", + }, + }, Aliases: []string{"r"}, Action: func(ctx context.Context, cmd *cli.Command) error { if cmd.Args().Present() { return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " ")) } - return c.Radio() + // prompt is the only one we suypport + if prompt := cmd.String("prompt"); prompt != "" { + return c.RadioFromPrompt(ctx, prompt, cmd.String("mode")) + } + return fmt.Errorf("radio requires a prompt") }, Category: "Radio", }, @@ -308,18 +331,6 @@ func Run(c *commands.Commander, s fx.Shutdowner) { }, Category: "Radio", }, - { - Name: "refillradio", - Usage: "Refills the radio queue with similar songs", - Aliases: []string{"rr"}, - Action: func(ctx context.Context, cmd *cli.Command) error { - if cmd.Args().Present() { - return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " ")) - } - return c.RefillRadio() - }, - Category: "Radio", - }, { Name: "status", Usage: "Prints the current status", diff --git a/src/components/commands/commander.go b/src/components/commands/commander.go index 47218be..fc7e01a 100644 --- a/src/components/commands/commander.go +++ b/src/components/commands/commander.go @@ -2,6 +2,7 @@ package commands import ( "context" + "database/sql" "log/slog" "sync" @@ -10,6 +11,7 @@ import ( "git.asdf.cafe/abs3nt/gspot/src/components/cache" "git.asdf.cafe/abs3nt/gspot/src/config" + "git.asdf.cafe/abs3nt/gspot/src/listenbrainz" "git.asdf.cafe/abs3nt/gspot/src/services" ) @@ -26,6 +28,8 @@ type CommanderParams struct { Log *slog.Logger Cache *cache.Cache Config *config.Config + Lb *listenbrainz.ListenBrainz + Db *sql.DB } type Commander struct { @@ -36,6 +40,8 @@ type Commander struct { mu sync.RWMutex cl *spotify.Client conf *config.Config + lb *listenbrainz.ListenBrainz + db *sql.DB } func NewCommander(p CommanderParams) CommanderResult { @@ -44,6 +50,8 @@ func NewCommander(p CommanderParams) CommanderResult { Log: p.Log, Cache: p.Cache, conf: p.Config, + lb: p.Lb, + db: p.Db, } return CommanderResult{ Commander: c, diff --git a/src/components/commands/play.go b/src/components/commands/play.go index c084903..4d68cb8 100644 --- a/src/components/commands/play.go +++ b/src/components/commands/play.go @@ -35,7 +35,7 @@ func (c *Commander) PlayLikedSongs(position int) error { if err != nil { return err } - playlist, _, err := c.GetRadioPlaylist("Saved Songs") + playlist, err := c.GetRadioPlaylist("Saved Songs") if err != nil { return err } diff --git a/src/components/commands/radio.go b/src/components/commands/radio.go index 6c90d4e..06c0c9d 100644 --- a/src/components/commands/radio.go +++ b/src/components/commands/radio.go @@ -1,226 +1,67 @@ package commands import ( + "context" "database/sql" "encoding/json" "errors" "fmt" - "math" "math/rand" "os" "path/filepath" - "time" + "git.asdf.cafe/abs3nt/gspot/src/listenbrainz" "github.com/zmb3/spotify/v2" _ "modernc.org/sqlite" ) func (c *Commander) Radio() error { - currentSong, err := c.Client().PlayerCurrentlyPlaying(c.Context) - if err != nil { - return err - } - if currentSong.Item != nil { - return c.RadioGivenSong(currentSong.Item.SimpleTrack, currentSong.Progress) - } - _, err = c.activateDevice() - if err != nil { - return err - } - tracks, err := c.Client().CurrentUsersTracks(c.Context, spotify.Limit(10)) - if err != nil { - return err - } - return c.RadioGivenSong(tracks.Tracks[rand.Intn(len(tracks.Tracks))].SimpleTrack, 0) + return nil } -func (c *Commander) RadioFromPlaylist(playlist spotify.SimplePlaylist) error { - playlistPage, err := c.Client().GetPlaylistItems( - c.Context, - playlist.ID, - spotify.Limit(50), - spotify.Offset(0), - ) - if err != nil { - return err - } - pageSongs := playlistPage.Items - rand.Shuffle(len(pageSongs), func(i, j int) { pageSongs[i], pageSongs[j] = pageSongs[j], pageSongs[i] }) - seedCount := 5 - if len(pageSongs) < seedCount { - seedCount = len(pageSongs) - } - seedIds := []spotify.ID{} - for idx, song := range pageSongs { - if idx >= seedCount { - break - } - seedIds = append(seedIds, song.Track.Track.ID) - } - return c.RadioGivenList(seedIds[:seedCount], playlist.Name) -} - -func (c *Commander) RadioFromSavedTracks() error { - savedSongs, err := c.Client().CurrentUsersTracks(c.Context, spotify.Limit(50), spotify.Offset(0)) - if err != nil { - return err - } - if savedSongs.Total == 0 { - return fmt.Errorf("you have no saved songs") - } - pages := int(math.Ceil(float64(savedSongs.Total) / 50)) - randomPage := 1 - if pages > 1 { - randomPage = rand.Intn(pages-1) + 1 - } - trackPage, err := c.Client().CurrentUsersTracks(c.Context, spotify.Limit(50), spotify.Offset(randomPage*50)) - if err != nil { - return err - } - pageSongs := trackPage.Tracks - rand.Shuffle(len(pageSongs), func(i, j int) { pageSongs[i], pageSongs[j] = pageSongs[j], pageSongs[i] }) - seedCount := 4 - seedIds := []spotify.ID{} - for idx, song := range pageSongs { - if idx >= seedCount { - break - } - seedIds = append(seedIds, song.ID) - } - seedIds = append(seedIds, savedSongs.Tracks[0].ID) - return c.RadioGivenList(seedIds, "Saved Tracks") -} - -func (c *Commander) RadioGivenArtist(artist spotify.SimpleArtist) error { - seed := spotify.Seeds{ - Artists: []spotify.ID{artist.ID}, - } - recomendations, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(100)) - if err != nil { - return err - } - recomendationIds := []spotify.ID{} - for _, song := range recomendations.Tracks { - recomendationIds = append(recomendationIds, song.ID) - } - err = c.ClearRadio() - if err != nil { - return err - } - radioPlaylist, db, err := c.GetRadioPlaylist(artist.Name) - if err != nil { - return err - } - queue := []spotify.ID{} - for _, rec := range recomendationIds { - exists, err := c.SongExists(db, rec) - if err != nil { - return err - } - if !exists { - _, err := db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(rec))) - if err != nil { - return err - } - queue = append(queue, rec) - } - } - _, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, queue...) - if err != nil { - return err - } - err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{ - PlaybackContext: &radioPlaylist.URI, +func (c *Commander) GetRecomendationIdsForPrompt(ctx context.Context, prompt string, mode string) ([]spotify.ID, error) { + radioResp, err := c.lb.RequestRadio(ctx, &listenbrainz.RadioRequest{ + Prompt: prompt, + Mode: mode, }) if err != nil { - return err + return nil, err } - err = c.Client().Repeat(c.Context, "context") + ids := []spotify.ID{} + for _, v := range radioResp.Tracks { + match, err := c.lb.MatchTrack(ctx, &v) + if err != nil { + if errors.Is(err, listenbrainz.ErrNoMatch) { + c.Log.Warn("no match for track", "err", err) + } else { + c.Log.Error("failed to match track", "err", err) + } + continue + } + if match.SpotifyId == "" { + continue + } + ids = append(ids, spotify.ID(match.SpotifyId)) + } + return ids, nil +} + +func (c *Commander) RadioFromPrompt(ctx context.Context, prompt string, mode string) error { + list, err := c.GetRecomendationIdsForPrompt(ctx, prompt, mode) if err != nil { return err } - for i := 0; i < 4; i++ { - id := rand.Intn(len(recomendationIds)-2) + 1 - seed := spotify.Seeds{ - Tracks: []spotify.ID{recomendationIds[id]}, - } - additionalRecs, err := c.Client(). - GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(100)) - if err != nil { - return err - } - additionalRecsIds := []spotify.ID{} - for _, song := range additionalRecs.Tracks { - exists, err := c.SongExists(db, song.ID) - if err != nil { - return err - } - if !exists { - _, err = db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(song.ID))) - if err != nil { - return err - } - additionalRecsIds = append(additionalRecsIds, song.ID) - } - } - _, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, additionalRecsIds...) - if err != nil { - return err - } + err = c.RadioGivenList(list, fmt.Sprintf("lb - %s - %s", prompt, mode)) + if err != nil { + return err } return nil } -func (c *Commander) RadioGivenSong(song spotify.SimpleTrack, pos spotify.Numeric) error { - start := time.Now().UnixMilli() - seed := spotify.Seeds{ - Tracks: []spotify.ID{song.ID}, - } - recomendations, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(99)) - if err != nil { - return err - } - recomendationIds := []spotify.ID{} - for _, song := range recomendations.Tracks { - recomendationIds = append(recomendationIds, song.ID) - } - err = c.ClearRadio() - if err != nil { - return err - } - radioPlaylist, db, err := c.GetRadioPlaylist(song.Name) - if err != nil { - return err - } - _, err = db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(song.ID))) - if err != nil { - return err - } - queue := []spotify.ID{song.ID} - for _, rec := range recomendationIds { - exists, err := c.SongExists(db, rec) - if err != nil { - return err - } - if !exists { - _, err := db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(rec))) - if err != nil { - return err - } - queue = append(queue, rec) - } - } - _, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, queue...) - if err != nil { - return err - } - delay := time.Now().UnixMilli() - start - if pos != 0 { - pos = pos + spotify.Numeric(delay) - } - err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{ +func (c *Commander) PlayRadio(radioPlaylist *spotify.FullPlaylist, pos int) error { + err := c.Client().PlayOpt(c.Context, &spotify.PlayOptions{ PlaybackContext: &radioPlaylist.URI, - PositionMs: pos, + PositionMs: spotify.Numeric(pos), }) if err != nil { if isNoActiveError(err) { @@ -231,51 +72,18 @@ func (c *Commander) RadioGivenSong(song spotify.SimpleTrack, pos spotify.Numeric err = c.Client().PlayOpt(c.Context, &spotify.PlayOptions{ PlaybackContext: &radioPlaylist.URI, DeviceID: &deviceID, - PositionMs: pos, + PositionMs: spotify.Numeric(pos), }) if err != nil { return err } } } - err = c.Client().Repeat(c.Context, "context") - if err != nil { - return err - } - for i := 0; i < 4; i++ { - id := rand.Intn(len(recomendationIds)-2) + 1 - seed := spotify.Seeds{ - Tracks: []spotify.ID{recomendationIds[id]}, - } - additionalRecs, err := c.Client(). - GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(100)) - if err != nil { - return err - } - additionalRecsIds := []spotify.ID{} - for _, song := range additionalRecs.Tracks { - exists, err := c.SongExists(db, song.ID) - if err != nil { - return err - } - if !exists { - _, err = db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(song.ID))) - if err != nil { - return err - } - additionalRecsIds = append(additionalRecsIds, song.ID) - } - } - _, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, additionalRecsIds...) - if err != nil { - return err - } - } return nil } func (c *Commander) ClearRadio() error { - radioPlaylist, db, err := c.GetRadioPlaylist("") + radioPlaylist, err := c.GetRadioPlaylist("") if err != nil { return err } @@ -283,57 +91,49 @@ func (c *Commander) ClearRadio() error { if err != nil { return err } - _, _ = db.Query("DROP TABLE IF EXISTS radio") + _, _ = c.db.Query("DROP TABLE IF EXISTS radio") configDir, _ := os.UserConfigDir() os.Remove(filepath.Join(configDir, "gspot/radio.json")) _ = c.Client().Pause(c.Context) return nil } -func (c *Commander) GetRadioPlaylist(name string) (*spotify.FullPlaylist, *sql.DB, error) { +func (c *Commander) GetRadioPlaylist(name string) (*spotify.FullPlaylist, error) { configDir, _ := os.UserConfigDir() playlistFile, err := os.ReadFile(filepath.Join(configDir, "gspot/radio.json")) if errors.Is(err, os.ErrNotExist) { return c.CreateRadioPlaylist(name) } if err != nil { - return nil, nil, err + return nil, err } var playlist *spotify.FullPlaylist err = json.Unmarshal(playlistFile, &playlist) if err != nil { - return nil, nil, err + return nil, err } - db, err := sql.Open("sqlite", filepath.Join(configDir, "gspot/radio.db")) - if err != nil { - return nil, nil, err - } - return playlist, db, nil + return playlist, nil } -func (c *Commander) CreateRadioPlaylist(name string) (*spotify.FullPlaylist, *sql.DB, error) { +func (c *Commander) CreateRadioPlaylist(name string) (*spotify.FullPlaylist, error) { // private flag doesnt work configDir, _ := os.UserConfigDir() playlist, err := c.Client(). CreatePlaylistForUser(c.Context, c.User.ID, name+" - autoradio", "Automanaged radio playlist", false, false) if err != nil { - return nil, nil, err + return nil, err } raw, err := json.MarshalIndent(playlist, "", " ") if err != nil { - return nil, nil, err + return nil, err } err = os.WriteFile(filepath.Join(configDir, "gspot/radio.json"), raw, 0o600) if err != nil { - return nil, nil, err + return nil, err } - db, err := sql.Open("sqlite", filepath.Join(configDir, "gspot/radio.db")) - if err != nil { - return nil, nil, err - } - _, _ = db.QueryContext(c.Context, "DROP TABLE IF EXISTS radio") - _, _ = db.QueryContext(c.Context, "CREATE TABLE IF NOT EXISTS radio (id string PRIMARY KEY)") - return playlist, db, nil + _, _ = c.db.QueryContext(c.Context, "DROP TABLE IF EXISTS radio") + _, _ = c.db.QueryContext(c.Context, "CREATE TABLE IF NOT EXISTS radio (id string PRIMARY KEY)") + return playlist, nil } func (c *Commander) SongExists(db *sql.DB, song spotify.ID) (bool, error) { @@ -351,226 +151,23 @@ func (c *Commander) SongExists(db *sql.DB, song spotify.ID) (bool, error) { return true, nil } -func (c *Commander) RefillRadio() error { - status, err := c.Client().PlayerCurrentlyPlaying(c.Context) - if err != nil { - return err - } - paused := false - if !status.Playing { - paused = true - } - toRemove := []spotify.ID{} - radioPlaylist, db, err := c.GetRadioPlaylist("") - if err != nil { - return err - } - - playlistItems, err := c.Client().GetPlaylistItems(c.Context, radioPlaylist.ID) - if err != nil { - return fmt.Errorf("orig playlist items: %w", err) - } - - if status.PlaybackContext.URI != radioPlaylist.URI || paused { - return c.RadioFromPlaylist(radioPlaylist.SimplePlaylist) - } - - page := 0 - for { - tracks, err := c.Client().GetPlaylistItems(c.Context, radioPlaylist.ID, spotify.Limit(50), spotify.Offset(page*50)) - if err != nil { - return fmt.Errorf("tracks: %w", err) - } - if len(tracks.Items) == 0 { - break - } - for _, track := range tracks.Items { - if track.Track.Track.ID == status.Item.ID { - break - } - toRemove = append(toRemove, track.Track.Track.ID) - } - page++ - } - if len(toRemove) > 0 { - var trackGroups []spotify.ID - for idx, item := range toRemove { - if idx%100 == 0 && idx != 0 { - _, err = c.Client().RemoveTracksFromPlaylist(c.Context, radioPlaylist.ID, trackGroups...) - trackGroups = []spotify.ID{} - } - trackGroups = append(trackGroups, item) - if err != nil { - return fmt.Errorf("error clearing playlist: %w", err) - } - } - _, err := c.Client().RemoveTracksFromPlaylist(c.Context, radioPlaylist.ID, trackGroups...) - if err != nil { - return err - } - } - - toAdd := 500 - (int(playlistItems.Total) - len(toRemove)) - playlistItems, err = c.Client().GetPlaylistItems(c.Context, radioPlaylist.ID) - if err != nil { - return fmt.Errorf("playlist items: %w", err) - } - total := playlistItems.Total - pages := int(math.Ceil(float64(total) / 50)) - randomPage := 1 - if pages > 1 { - randomPage = rand.Intn(pages-1) + 1 - } - playlistPage, err := c.Client(). - GetPlaylistItems(c.Context, radioPlaylist.ID, spotify.Limit(50), spotify.Offset((randomPage-1)*50)) - if err != nil { - return fmt.Errorf("playlist page: %w", err) - } - pageSongs := playlistPage.Items - rand.Shuffle(len(pageSongs), func(i, j int) { pageSongs[i], pageSongs[j] = pageSongs[j], pageSongs[i] }) - seedCount := 5 - if len(pageSongs) < seedCount { - seedCount = len(pageSongs) - } - seedIds := []spotify.ID{} - for idx, song := range pageSongs { - if idx >= seedCount { - break - } - seedIds = append(seedIds, song.Track.Track.ID) - } - seed := spotify.Seeds{ - Tracks: seedIds, - } - recomendations, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(95)) - if err != nil { - return err - } - recomendationIds := []spotify.ID{} - for _, song := range recomendations.Tracks { - exists, err := c.SongExists(db, song.ID) - if err != nil { - return fmt.Errorf("err check song existnce: %w", err) - } - if !exists { - recomendationIds = append(recomendationIds, song.ID) - } - } - queue := []spotify.ID{} - for idx, rec := range recomendationIds { - if idx > toAdd { - break - } - _, err = db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", rec.String())) - if err != nil { - return err - } - queue = append(queue, rec) - } - toAdd -= len(queue) - _, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, queue...) - if err != nil { - return fmt.Errorf("add tracks: %w", err) - } - err = c.Client().Repeat(c.Context, "context") - if err != nil { - return fmt.Errorf("repeat: %w", err) - } - for toAdd > 0 { - id := rand.Intn(len(recomendationIds)-2) + 1 - seed := spotify.Seeds{ - Tracks: []spotify.ID{recomendationIds[id]}, - } - additionalRecs, err := c.Client(). - GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(100)) - if err != nil { - return fmt.Errorf("get recs: %w", err) - } - additionalRecsIds := []spotify.ID{} - for idx, song := range additionalRecs.Tracks { - exists, err := c.SongExists(db, song.ID) - if err != nil { - return fmt.Errorf("check song existence: %w", err) - } - if !exists { - if idx > toAdd { - break - } - additionalRecsIds = append(additionalRecsIds, song.ID) - queue = append(queue, song.ID) - } - } - toAdd -= len(queue) - _, err = c.Client().AddTracksToPlaylist(c.Context, radioPlaylist.ID, additionalRecsIds...) - if err != nil { - return fmt.Errorf("add tracks to playlist: %w", err) - } - } - return nil -} - -func (c *Commander) RadioFromAlbum(album spotify.SimpleAlbum) error { - tracks, err := c.AlbumTracks(album.ID, 1) - if err != nil { - return err - } - total := tracks.Total - if total == 0 { - return fmt.Errorf("this playlist is empty") - } - pages := int(math.Ceil(float64(total) / 50)) - randomPage := 1 - if pages > 1 { - randomPage = rand.Intn(pages-1) + 1 - } - albumTrackPage, err := c.AlbumTracks(album.ID, randomPage) - if err != nil { - return err - } - pageSongs := albumTrackPage.Tracks - rand.Shuffle(len(pageSongs), func(i, j int) { pageSongs[i], pageSongs[j] = pageSongs[j], pageSongs[i] }) - seedCount := 5 - if len(pageSongs) < seedCount { - seedCount = len(pageSongs) - } - seedIds := []spotify.ID{} - for idx, song := range pageSongs { - if idx >= seedCount { - break - } - seedIds = append(seedIds, song.ID) - } - return c.RadioGivenList(seedIds[:seedCount], album.Name) -} - func (c *Commander) RadioGivenList(songs []spotify.ID, name string) error { - seed := spotify.Seeds{ - Tracks: songs, - } - recomendations, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(99)) + err := c.ClearRadio() if err != nil { return err } - recomendationIds := []spotify.ID{} - for _, song := range recomendations.Tracks { - recomendationIds = append(recomendationIds, song.ID) - } - err = c.ClearRadio() - if err != nil { - return err - } - radioPlaylist, db, err := c.GetRadioPlaylist(name) + radioPlaylist, err := c.GetRadioPlaylist(name) if err != nil { return err } queue := []spotify.ID{songs[0]} - for _, rec := range recomendationIds { - exists, err := c.SongExists(db, rec) + for _, rec := range songs { + exists, err := c.SongExists(c.db, rec) if err != nil { return err } if !exists { - _, err := db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(rec))) + _, err := c.db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(rec))) if err != nil { return err } @@ -581,28 +178,14 @@ 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 + id := rand.Intn(len(songs)-2) + 1 seed := spotify.Seeds{ - Tracks: []spotify.ID{recomendationIds[id]}, + Tracks: []spotify.ID{songs[id]}, } additionalRecs, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(100)) if err != nil { @@ -610,12 +193,12 @@ func (c *Commander) RadioGivenList(songs []spotify.ID, name string) error { } additionalRecsIds := []spotify.ID{} for _, song := range additionalRecs.Tracks { - exists, err := c.SongExists(db, song.ID) + exists, err := c.SongExists(c.db, song.ID) if err != nil { return err } if !exists { - _, err = db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(song.ID))) + _, err = c.db.QueryContext(c.Context, fmt.Sprintf("INSERT INTO radio (id) VALUES('%s')", string(song.ID))) if err != nil { return err } diff --git a/src/components/sqldb/component.go b/src/components/sqldb/component.go new file mode 100644 index 0000000..9466d80 --- /dev/null +++ b/src/components/sqldb/component.go @@ -0,0 +1,16 @@ +package sqldb + +import ( + "database/sql" + "os" + "path/filepath" +) + +func New() (*sql.DB, error) { + configDir, _ := os.UserConfigDir() + db, err := sql.Open("sqlite", filepath.Join(configDir, "gspot/radio.db")) + if err != nil { + return nil, err + } + return db, nil +} diff --git a/src/components/tui/main.go b/src/components/tui/main.go index b8431e7..6e171d1 100644 --- a/src/components/tui/main.go +++ b/src/components/tui/main.go @@ -90,97 +90,119 @@ type mainModel struct { func (m *mainModel) PlayRadio() { go m.SendMessage("Starting radio for "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second) - selectedItem := m.list.SelectedItem().(mainItem).SpotifyItem - switch item := selectedItem.(type) { - case spotify.SimplePlaylist: - go func() { - err := m.commands.RadioFromPlaylist(item) - if err != nil { - m.SendMessage(err.Error(), 5*time.Second) - } - }() - return - case *spotify.SavedTrackPage: - go func() { - err := m.commands.RadioFromSavedTracks() - if err != nil { - m.SendMessage(err.Error(), 5*time.Second) - } - }() - return - case spotify.SimpleAlbum: - go func() { - err := m.commands.RadioFromAlbum(item) - if err != nil { - m.SendMessage(err.Error(), 5*time.Second) - } - }() - return - case spotify.FullAlbum: - go func() { - err := m.commands.RadioFromAlbum(item.SimpleAlbum) - if err != nil { - m.SendMessage(err.Error(), 5*time.Second) - } - }() - return - case spotify.SimpleArtist: - go func() { - err := m.commands.RadioGivenArtist(item) - if err != nil { - m.SendMessage(err.Error(), 5*time.Second) - } - }() - return - case spotify.FullArtist: - go func() { - err := m.commands.RadioGivenArtist(item.SimpleArtist) - if err != nil { - m.SendMessage(err.Error(), 5*time.Second) - } - }() - return - case spotify.SimpleTrack: - go func() { - err := m.commands.RadioGivenSong(item, 0) - if err != nil { - m.SendMessage(err.Error(), 5*time.Second) - } - }() - return - case spotify.FullTrack: - go func() { - err := m.commands.RadioGivenSong(item.SimpleTrack, 0) - if err != nil { - m.SendMessage(err.Error(), 5*time.Second) - } - }() - return - case spotify.PlaylistTrack: - go func() { - err := m.commands.RadioGivenSong(item.Track.SimpleTrack, 0) - if err != nil { - m.SendMessage(err.Error(), 5*time.Second) - } - }() - return - case spotify.PlaylistItem: - go func() { - err := m.commands.RadioGivenSong(item.Track.Track.SimpleTrack, 0) - if err != nil { - m.SendMessage(err.Error(), 5*time.Second) - } - }() - return - case spotify.SavedTrack: - go func() { - err := m.commands.RadioGivenSong(item.SimpleTrack, 0) - if err != nil { - m.SendMessage(err.Error(), 5*time.Second) - } - }() - return - } + // selectedItem := m.list.SelectedItem().(mainItem).SpotifyItem + // switch item := selectedItem.(type) { + // + // case spotify.SimplePlaylist: + // + // go func() { + // err := m.commands.RadioFromPlaylist(item) + // if err != nil { + // m.SendMessage(err.Error(), 5*time.Second) + // } + // }() + // return + // + // case *spotify.SavedTrackPage: + // + // go func() { + // err := m.commands.RadioFromSavedTracks() + // if err != nil { + // m.SendMessage(err.Error(), 5*time.Second) + // } + // }() + // return + // + // case spotify.SimpleAlbum: + // + // go func() { + // err := m.commands.RadioFromAlbum(item) + // if err != nil { + // m.SendMessage(err.Error(), 5*time.Second) + // } + // }() + // return + // + // case spotify.FullAlbum: + // + // go func() { + // err := m.commands.RadioFromAlbum(item.SimpleAlbum) + // if err != nil { + // m.SendMessage(err.Error(), 5*time.Second) + // } + // }() + // return + // + // case spotify.SimpleArtist: + // + // go func() { + // err := m.commands.RadioGivenArtist(item) + // if err != nil { + // m.SendMessage(err.Error(), 5*time.Second) + // } + // }() + // return + // + // case spotify.FullArtist: + // + // go func() { + // err := m.commands.RadioGivenArtist(item.SimpleArtist) + // if err != nil { + // m.SendMessage(err.Error(), 5*time.Second) + // } + // }() + // return + // + // case spotify.SimpleTrack: + // + // go func() { + // err := m.commands.RadioGivenSong(item, 0) + // if err != nil { + // m.SendMessage(err.Error(), 5*time.Second) + // } + // }() + // return + // + // case spotify.FullTrack: + // + // go func() { + // err := m.commands.RadioGivenSong(item.SimpleTrack, 0) + // if err != nil { + // m.SendMessage(err.Error(), 5*time.Second) + // } + // }() + // return + // + // case spotify.PlaylistTrack: + // + // go func() { + // err := m.commands.RadioGivenSong(item.Track.SimpleTrack, 0) + // if err != nil { + // m.SendMessage(err.Error(), 5*time.Second) + // } + // }() + // return + // + // case spotify.PlaylistItem: + // + // go func() { + // err := m.commands.RadioGivenSong(item.Track.Track.SimpleTrack, 0) + // if err != nil { + // m.SendMessage(err.Error(), 5*time.Second) + // } + // }() + // return + // + // case spotify.SavedTrack: + // + // go func() { + // err := m.commands.RadioGivenSong(item.SimpleTrack, 0) + // if err != nil { + // m.SendMessage(err.Error(), 5*time.Second) + // } + // }() + // return + // } } func (m *mainModel) GoBack() (tea.Cmd, error) { diff --git a/src/config/config.go b/src/config/config.go index b7550b9..5f12182 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -1,10 +1,13 @@ package config type Config struct { - ClientID string `yaml:"client_id"` - ClientSecret string `yaml:"client_secret"` - ClientSecretCmd string `yaml:"client_secret_cmd"` - Port string `yaml:"port"` - LogLevel string `yaml:"log_level" default:"info"` - LogOutput string `yaml:"log_output" default:"stdout"` + ListenBrainzEndpoint string `yaml:"listenbrainz_endpoint" default:"https://api.listenbrainz.org"` + ListenBrainzLabsEndpoint string `yaml:"listenbrainz_labs_endpoint" default:"https://labs.api.listenbrainz.org"` + ListenBrainzUserToken string `yaml:"listenbrainz_user_token"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + ClientSecretCmd string `yaml:"client_secret_cmd"` + Port string `yaml:"port"` + LogLevel string `yaml:"log_level" default:"info"` + LogOutput string `yaml:"log_output" default:"stdout"` } diff --git a/src/config/logger.go b/src/config/logger.go new file mode 100644 index 0000000..184db6f --- /dev/null +++ b/src/config/logger.go @@ -0,0 +1,22 @@ +package config + +import ( + "context" + "log/slog" +) + +type loggerContextKeyType struct{} + +var loggerContextKey = loggerContextKeyType{} + +func WithLogger(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, loggerContextKey, logger) +} + +func GetLogger(ctx context.Context) *slog.Logger { + logger, ok := ctx.Value(loggerContextKey).(*slog.Logger) + if !ok { + return slog.Default() + } + return logger +} diff --git a/src/listenbrainz/listenbrainz.go b/src/listenbrainz/listenbrainz.go new file mode 100644 index 0000000..a5e7760 --- /dev/null +++ b/src/listenbrainz/listenbrainz.go @@ -0,0 +1,207 @@ +package listenbrainz + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "git.asdf.cafe/abs3nt/gspot/src/config" + "github.com/go-resty/resty/v2" +) + +type ListenBrainz struct { + client *resty.Client + labs *resty.Client + db *sql.DB +} + +func New( + conf *config.Config, + db *sql.DB, +) *ListenBrainz { + c := resty.New().SetBaseURL(conf.ListenBrainzEndpoint) + if conf.ListenBrainzUserToken != "" { + c = c.SetHeader("Authorization", "Token "+conf.ListenBrainzUserToken) + } + return &ListenBrainz{ + client: c, + labs: resty.New().SetBaseURL("https://labs.api.listenbrainz.org"), + db: db, + } +} + +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"` +} + +type ApiError struct { + Code int `json:"code"` + Error string `json:"error"` +} + +func (o *ListenBrainz) RequestRadio(ctx context.Context, req *RadioRequest) (*RadioTracksResponse, error) { + log := config.GetLogger(ctx) + log.Info("Requesting radio", "prompt", req.Prompt, "mode", req.Mode) + var res RadioApiResponse + var errResp ApiError + resp, err := o.client.R(). + SetResult(&res). + SetError(&errResp). + SetQueryParam("prompt", req.Prompt). + SetQueryParam("mode", req.Mode). + Get("/1/explore/lb-radio") + if err != nil { + return nil, err + } + switch { + case resp.StatusCode() == 200: + default: + return nil, fmt.Errorf("radio %s: %s", resp.Status(), errResp.Error) + } + 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) { + return o.matchTrack(ctx, track) +} +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 mbid string + for _, v := range track.Identifier { + if strings.HasPrefix(v, "https://musicbrainz.org/recording/") { + mbid = strings.TrimPrefix(v, "https://musicbrainz.org/recording/") + break + } + } + if mbid == "" { + return nil, fmt.Errorf("%w: no mbid", ErrNoMatch) + } + var matches []TrackMatch + // there are mbids, so try to get the first one that is valid + var errResp ApiError + resp, err := o.labs.R(). + SetResult(&matches). + SetQueryParam("recording_mbid", mbid). + Get("/spotify-id-from-mbid/json") + if err != nil { + return nil, err + } + if resp.StatusCode() != 200 { + return nil, fmt.Errorf("spotify-id-from-mbid %s: %s", resp.Status(), errResp.Error) + } + 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"` +}