WIP: use the listenbrainz radio api #54

Draft
a wants to merge 2 commits from listenbrainz into master
10 changed files with 288 additions and 584 deletions
Showing only changes of commit a33d013525 - Show all commits

View File

@ -13,6 +13,9 @@ import (
"git.asdf.cafe/abs3nt/gspot/src/components/cli" "git.asdf.cafe/abs3nt/gspot/src/components/cli"
"git.asdf.cafe/abs3nt/gspot/src/components/commands" "git.asdf.cafe/abs3nt/gspot/src/components/commands"
"git.asdf.cafe/abs3nt/gspot/src/components/logger" "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" "git.asdf.cafe/abs3nt/gspot/src/services"
) )
@ -30,6 +33,8 @@ func main() {
Context, Context,
cache.NewCache, cache.NewCache,
commands.NewCommander, commands.NewCommander,
sqldb.New,
listenbrainz.New,
logger.NewLogger, logger.NewLogger,
), ),
fx.Invoke( fx.Invoke(
@ -52,6 +57,7 @@ func Context(
log = slog.Default() log = slog.Default()
} }
ctx, cn := context.WithCancelCause(context.Background()) ctx, cn := context.WithCancelCause(context.Background())
ctx = config.WithLogger(ctx, log)
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStop: func(ctx context.Context) error { OnStop: func(ctx context.Context) error {
cn(fmt.Errorf("%w: %w", context.Canceled, ErrContextShutdown)) cn(fmt.Errorf("%w: %w", context.Canceled, ErrContextShutdown))

View File

@ -286,13 +286,36 @@ func Run(c *commands.Commander, s fx.Shutdowner) {
}, },
{ {
Name: "radio", Name: "radio",
Usage: "Starts a radio from the current song", 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"}, Aliases: []string{"r"},
Action: func(ctx context.Context, cmd *cli.Command) error { Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Present() { if cmd.Args().Present() {
return fmt.Errorf("unexpected arguments: %s", strings.Join(cmd.Args().Slice(), " ")) 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", Category: "Radio",
}, },
@ -308,18 +331,6 @@ func Run(c *commands.Commander, s fx.Shutdowner) {
}, },
Category: "Radio", 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", Name: "status",
Usage: "Prints the current status", Usage: "Prints the current status",

View File

@ -2,6 +2,7 @@ package commands
import ( import (
"context" "context"
"database/sql"
"log/slog" "log/slog"
"sync" "sync"
@ -10,6 +11,7 @@ import (
"git.asdf.cafe/abs3nt/gspot/src/components/cache" "git.asdf.cafe/abs3nt/gspot/src/components/cache"
"git.asdf.cafe/abs3nt/gspot/src/config" "git.asdf.cafe/abs3nt/gspot/src/config"
"git.asdf.cafe/abs3nt/gspot/src/listenbrainz"
"git.asdf.cafe/abs3nt/gspot/src/services" "git.asdf.cafe/abs3nt/gspot/src/services"
) )
@ -26,6 +28,8 @@ type CommanderParams struct {
Log *slog.Logger Log *slog.Logger
Cache *cache.Cache Cache *cache.Cache
Config *config.Config Config *config.Config
Lb *listenbrainz.ListenBrainz
Db *sql.DB
} }
type Commander struct { type Commander struct {
@ -36,6 +40,8 @@ type Commander struct {
mu sync.RWMutex mu sync.RWMutex
cl *spotify.Client cl *spotify.Client
conf *config.Config conf *config.Config
lb *listenbrainz.ListenBrainz
db *sql.DB
} }
func NewCommander(p CommanderParams) CommanderResult { func NewCommander(p CommanderParams) CommanderResult {
@ -44,6 +50,8 @@ func NewCommander(p CommanderParams) CommanderResult {
Log: p.Log, Log: p.Log,
Cache: p.Cache, Cache: p.Cache,
conf: p.Config, conf: p.Config,
lb: p.Lb,
db: p.Db,
} }
return CommanderResult{ return CommanderResult{
Commander: c, Commander: c,

View File

@ -35,7 +35,7 @@ func (c *Commander) PlayLikedSongs(position int) error {
if err != nil { if err != nil {
return err return err
} }
playlist, _, err := c.GetRadioPlaylist("Saved Songs") playlist, err := c.GetRadioPlaylist("Saved Songs")
if err != nil { if err != nil {
return err return err
} }

View File

@ -1,260 +1,60 @@
package commands package commands
import ( import (
"context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math"
"math/rand" "math/rand"
"os" "os"
"path/filepath" "path/filepath"
"time"
"git.asdf.cafe/abs3nt/gspot/src/listenbrainz"
"github.com/zmb3/spotify/v2" "github.com/zmb3/spotify/v2"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
func (c *Commander) Radio() error { func (c *Commander) Radio() error {
currentSong, err := c.Client().PlayerCurrentlyPlaying(c.Context)
if err != nil {
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 fmt.Errorf("failed to activate device: %w", err)
}
tracks, err := c.Client().CurrentUsersTracks(c.Context, spotify.Limit(10))
if err != nil {
return fmt.Errorf("failed to get current users tracks: %w", err)
}
return c.RadioGivenSong(tracks.Tracks[rand.Intn(len(tracks.Tracks))].SimpleTrack, 0)
}
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,
})
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 return nil
} }
func (c *Commander) RadioGivenSong(song spotify.SimpleTrack, pos spotify.Numeric) error { func (c *Commander) GetRecomendationIdsForPrompt(ctx context.Context, prompt string, mode string) ([]spotify.ID, error) {
start := time.Now().UnixMilli() radioResp, err := c.lb.RequestRadio(ctx, &listenbrainz.RadioRequest{
seed := spotify.Seeds{ Prompt: prompt,
Tracks: []spotify.ID{song.ID}, Mode: mode,
} })
recomendations, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(99))
if err != nil { if err != nil {
return fmt.Errorf("failed to get recommendations: %w", err) return nil, err
} }
recomendationIds := []spotify.ID{} ids := []spotify.ID{}
for _, song := range recomendations.Tracks { for _, v := range radioResp.Tracks {
recomendationIds = append(recomendationIds, song.ID) 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)
} }
err = c.ClearRadio() 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 { if err != nil {
return err return err
} }
radioPlaylist, db, err := c.GetRadioPlaylist(song.Name) err = c.RadioGivenList(list, fmt.Sprintf("lb - %s - %s", prompt, mode))
if err != nil { if err != nil {
return err 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.PlayRadio(radioPlaylist, int(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 return nil
} }
@ -283,7 +83,7 @@ func (c *Commander) PlayRadio(radioPlaylist *spotify.FullPlaylist, pos int) erro
} }
func (c *Commander) ClearRadio() error { func (c *Commander) ClearRadio() error {
radioPlaylist, db, err := c.GetRadioPlaylist("") radioPlaylist, err := c.GetRadioPlaylist("")
if err != nil { if err != nil {
return err return err
} }
@ -291,57 +91,49 @@ func (c *Commander) ClearRadio() error {
if err != nil { if err != nil {
return err return err
} }
_, _ = db.Query("DROP TABLE IF EXISTS radio") _, _ = c.db.Query("DROP TABLE IF EXISTS radio")
configDir, _ := os.UserConfigDir() configDir, _ := os.UserConfigDir()
os.Remove(filepath.Join(configDir, "gspot/radio.json")) os.Remove(filepath.Join(configDir, "gspot/radio.json"))
_ = c.Client().Pause(c.Context) _ = c.Client().Pause(c.Context)
return nil 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() configDir, _ := os.UserConfigDir()
playlistFile, err := os.ReadFile(filepath.Join(configDir, "gspot/radio.json")) playlistFile, err := os.ReadFile(filepath.Join(configDir, "gspot/radio.json"))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return c.CreateRadioPlaylist(name) return c.CreateRadioPlaylist(name)
} }
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
var playlist *spotify.FullPlaylist var playlist *spotify.FullPlaylist
err = json.Unmarshal(playlistFile, &playlist) err = json.Unmarshal(playlistFile, &playlist)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
db, err := sql.Open("sqlite", filepath.Join(configDir, "gspot/radio.db")) return playlist, nil
if err != nil {
return nil, nil, err
}
return playlist, db, 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 // private flag doesnt work
configDir, _ := os.UserConfigDir() configDir, _ := os.UserConfigDir()
playlist, err := c.Client(). playlist, err := c.Client().
CreatePlaylistForUser(c.Context, c.User.ID, name+" - autoradio", "Automanaged radio playlist", false, false) CreatePlaylistForUser(c.Context, c.User.ID, name+" - autoradio", "Automanaged radio playlist", false, false)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
raw, err := json.MarshalIndent(playlist, "", " ") raw, err := json.MarshalIndent(playlist, "", " ")
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
err = os.WriteFile(filepath.Join(configDir, "gspot/radio.json"), raw, 0o600) err = os.WriteFile(filepath.Join(configDir, "gspot/radio.json"), raw, 0o600)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
db, err := sql.Open("sqlite", filepath.Join(configDir, "gspot/radio.db")) _, _ = c.db.QueryContext(c.Context, "DROP TABLE IF EXISTS radio")
if err != nil { _, _ = c.db.QueryContext(c.Context, "CREATE TABLE IF NOT EXISTS radio (id string PRIMARY KEY)")
return nil, nil, err return playlist, nil
}
_, _ = 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
} }
func (c *Commander) SongExists(db *sql.DB, song spotify.ID) (bool, error) { func (c *Commander) SongExists(db *sql.DB, song spotify.ID) (bool, error) {
@ -359,226 +151,23 @@ func (c *Commander) SongExists(db *sql.DB, song spotify.ID) (bool, error) {
return true, nil 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 { func (c *Commander) RadioGivenList(songs []spotify.ID, name string) error {
seed := spotify.Seeds{ err := c.ClearRadio()
Tracks: songs,
}
recomendations, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(99))
if err != nil { if err != nil {
return err return err
} }
recomendationIds := []spotify.ID{} radioPlaylist, err := c.GetRadioPlaylist(name)
for _, song := range recomendations.Tracks {
recomendationIds = append(recomendationIds, song.ID)
}
err = c.ClearRadio()
if err != nil {
return err
}
radioPlaylist, db, err := c.GetRadioPlaylist(name)
if err != nil { if err != nil {
return err return err
} }
queue := []spotify.ID{songs[0]} queue := []spotify.ID{songs[0]}
for _, rec := range recomendationIds { for _, rec := range songs {
exists, err := c.SongExists(db, rec) exists, err := c.SongExists(c.db, rec)
if err != nil { if err != nil {
return err return err
} }
if !exists { 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 { if err != nil {
return err return err
} }
@ -594,9 +183,9 @@ func (c *Commander) RadioGivenList(songs []spotify.ID, name string) error {
return err return err
} }
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
id := rand.Intn(len(recomendationIds)-2) + 1 id := rand.Intn(len(songs)-2) + 1
seed := spotify.Seeds{ 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)) additionalRecs, err := c.Client().GetRecommendations(c.Context, seed, &spotify.TrackAttributes{}, spotify.Limit(100))
if err != nil { if err != nil {
@ -604,12 +193,12 @@ func (c *Commander) RadioGivenList(songs []spotify.ID, name string) error {
} }
additionalRecsIds := []spotify.ID{} additionalRecsIds := []spotify.ID{}
for _, song := range additionalRecs.Tracks { for _, song := range additionalRecs.Tracks {
exists, err := c.SongExists(db, song.ID) exists, err := c.SongExists(c.db, song.ID)
if err != nil { if err != nil {
return err return err
} }
if !exists { 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 { if err != nil {
return err return err
} }

View File

@ -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
}

View File

@ -90,97 +90,119 @@ type mainModel struct {
func (m *mainModel) PlayRadio() { func (m *mainModel) PlayRadio() {
go m.SendMessage("Starting radio for "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second) go m.SendMessage("Starting radio for "+m.list.SelectedItem().(mainItem).Title(), 2*time.Second)
selectedItem := m.list.SelectedItem().(mainItem).SpotifyItem // selectedItem := m.list.SelectedItem().(mainItem).SpotifyItem
switch item := selectedItem.(type) { // switch item := selectedItem.(type) {
case spotify.SimplePlaylist: //
go func() { // case spotify.SimplePlaylist:
err := m.commands.RadioFromPlaylist(item) //
if err != nil { // go func() {
m.SendMessage(err.Error(), 5*time.Second) // err := m.commands.RadioFromPlaylist(item)
} // if err != nil {
}() // m.SendMessage(err.Error(), 5*time.Second)
return // }
case *spotify.SavedTrackPage: // }()
go func() { // return
err := m.commands.RadioFromSavedTracks() //
if err != nil { // case *spotify.SavedTrackPage:
m.SendMessage(err.Error(), 5*time.Second) //
} // go func() {
}() // err := m.commands.RadioFromSavedTracks()
return // if err != nil {
case spotify.SimpleAlbum: // m.SendMessage(err.Error(), 5*time.Second)
go func() { // }
err := m.commands.RadioFromAlbum(item) // }()
if err != nil { // return
m.SendMessage(err.Error(), 5*time.Second) //
} // case spotify.SimpleAlbum:
}() //
return // go func() {
case spotify.FullAlbum: // err := m.commands.RadioFromAlbum(item)
go func() { // if err != nil {
err := m.commands.RadioFromAlbum(item.SimpleAlbum) // m.SendMessage(err.Error(), 5*time.Second)
if err != nil { // }
m.SendMessage(err.Error(), 5*time.Second) // }()
} // return
}() //
return // case spotify.FullAlbum:
case spotify.SimpleArtist: //
go func() { // go func() {
err := m.commands.RadioGivenArtist(item) // err := m.commands.RadioFromAlbum(item.SimpleAlbum)
if err != nil { // if err != nil {
m.SendMessage(err.Error(), 5*time.Second) // m.SendMessage(err.Error(), 5*time.Second)
} // }
}() // }()
return // return
case spotify.FullArtist: //
go func() { // case spotify.SimpleArtist:
err := m.commands.RadioGivenArtist(item.SimpleArtist) //
if err != nil { // go func() {
m.SendMessage(err.Error(), 5*time.Second) // err := m.commands.RadioGivenArtist(item)
} // if err != nil {
}() // m.SendMessage(err.Error(), 5*time.Second)
return // }
case spotify.SimpleTrack: // }()
go func() { // return
err := m.commands.RadioGivenSong(item, 0) //
if err != nil { // case spotify.FullArtist:
m.SendMessage(err.Error(), 5*time.Second) //
} // go func() {
}() // err := m.commands.RadioGivenArtist(item.SimpleArtist)
return // if err != nil {
case spotify.FullTrack: // m.SendMessage(err.Error(), 5*time.Second)
go func() { // }
err := m.commands.RadioGivenSong(item.SimpleTrack, 0) // }()
if err != nil { // return
m.SendMessage(err.Error(), 5*time.Second) //
} // case spotify.SimpleTrack:
}() //
return // go func() {
case spotify.PlaylistTrack: // err := m.commands.RadioGivenSong(item, 0)
go func() { // if err != nil {
err := m.commands.RadioGivenSong(item.Track.SimpleTrack, 0) // m.SendMessage(err.Error(), 5*time.Second)
if err != nil { // }
m.SendMessage(err.Error(), 5*time.Second) // }()
} // return
}() //
return // case spotify.FullTrack:
case spotify.PlaylistItem: //
go func() { // go func() {
err := m.commands.RadioGivenSong(item.Track.Track.SimpleTrack, 0) // err := m.commands.RadioGivenSong(item.SimpleTrack, 0)
if err != nil { // if err != nil {
m.SendMessage(err.Error(), 5*time.Second) // m.SendMessage(err.Error(), 5*time.Second)
} // }
}() // }()
return // return
case spotify.SavedTrack: //
go func() { // case spotify.PlaylistTrack:
err := m.commands.RadioGivenSong(item.SimpleTrack, 0) //
if err != nil { // go func() {
m.SendMessage(err.Error(), 5*time.Second) // err := m.commands.RadioGivenSong(item.Track.SimpleTrack, 0)
} // if err != nil {
}() // m.SendMessage(err.Error(), 5*time.Second)
return // }
} // }()
// 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) { func (m *mainModel) GoBack() (tea.Cmd, error) {

View File

@ -1,6 +1,9 @@
package config package config
type Config struct { type Config struct {
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"` ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"` ClientSecret string `yaml:"client_secret"`
ClientSecretCmd string `yaml:"client_secret_cmd"` ClientSecretCmd string `yaml:"client_secret_cmd"`

22
src/config/logger.go Normal file
View File

@ -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
}

View File

@ -2,28 +2,32 @@ package listenbrainz
import ( import (
"context" "context"
"database/sql"
"fmt" "fmt"
"strings"
"git.asdf.cafe/abs3nt/gspot/src/config"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
) )
type ListenBrainz struct { type ListenBrainz struct {
client *resty.Client client *resty.Client
labs *resty.Client labs *resty.Client
db *sql.DB
} }
func NewListenBrainz( func New(
Endpoint string, conf *config.Config,
ApiKey string, db *sql.DB,
) *ListenBrainz { ) *ListenBrainz {
c := resty.New().SetBaseURL(Endpoint) c := resty.New().SetBaseURL(conf.ListenBrainzEndpoint)
if ApiKey != "" { if conf.ListenBrainzUserToken != "" {
c = c.SetHeader("Authorization", "Token "+ApiKey) c = c.SetHeader("Authorization", "Token "+conf.ListenBrainzUserToken)
} }
return &ListenBrainz{ return &ListenBrainz{
client: c, client: c,
labs: resty.New().SetBaseURL("https://labs.api.listenbrainz.org"), labs: resty.New().SetBaseURL("https://labs.api.listenbrainz.org"),
db: db,
} }
} }
@ -60,12 +64,21 @@ type ApiTrack struct {
Title string `json:"title"` 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) { 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 res RadioApiResponse
var errResp ApiError
resp, err := o.client.R(). resp, err := o.client.R().
SetResult(&res). SetResult(&res).
SetError(&errResp).
SetQueryParam("prompt", req.Prompt). SetQueryParam("prompt", req.Prompt).
SetQueryParam("mode", req.Prompt). SetQueryParam("mode", req.Mode).
Get("/1/explore/lb-radio") Get("/1/explore/lb-radio")
if err != nil { if err != nil {
return nil, err return nil, err
@ -73,7 +86,7 @@ func (o *ListenBrainz) RequestRadio(ctx context.Context, req *RadioRequest) (*Ra
switch { switch {
case resp.StatusCode() == 200: case resp.StatusCode() == 200:
default: default:
return nil, fmt.Errorf("radio request code %d: %s", resp.StatusCode(), resp.Status()) return nil, fmt.Errorf("radio %s: %s", resp.Status(), errResp.Error)
} }
tracks := res.Payload.Jspf.Playlist.Track tracks := res.Payload.Jspf.Playlist.Track
return &RadioTracksResponse{ return &RadioTracksResponse{
@ -117,21 +130,35 @@ type TrackMatch struct {
var ErrNoMatch = fmt.Errorf("no match") var ErrNoMatch = fmt.Errorf("no match")
func (o *ListenBrainz) MatchTrack(ctx context.Context, track *ApiTrack) (*MatchedTrack, error) { 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 // refuse to match a track with no identifiers
if len(track.Identifier) == 0 { if len(track.Identifier) == 0 {
return nil, fmt.Errorf("%w: no identifier", ErrNoMatch) 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 var matches []TrackMatch
// there are mbids, so try to get the first one that is valid // there are mbids, so try to get the first one that is valid
var errResp ApiError
resp, err := o.labs.R(). resp, err := o.labs.R().
SetResult(&matches). SetResult(&matches).
SetQueryParam("recording_mbid", track.Identifier[0]). SetQueryParam("recording_mbid", mbid).
Get("/spotify-id-from-mbid/json") Get("/spotify-id-from-mbid/json")
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resp.StatusCode() != 200 { if resp.StatusCode() != 200 {
return nil, fmt.Errorf("labs request code %d: %s", resp.StatusCode(), resp.Status()) return nil, fmt.Errorf("spotify-id-from-mbid %s: %s", resp.Status(), errResp.Error)
} }
if len(matches) == 0 { if len(matches) == 0 {
return nil, fmt.Errorf("%w: no mbid", ErrNoMatch) return nil, fmt.Errorf("%w: no mbid", ErrNoMatch)