diff --git a/go.mod b/go.mod index 6912d9b..698bfdd 100644 --- a/go.mod +++ b/go.mod @@ -12,19 +12,24 @@ require ( github.com/cristalhq/aconfig/aconfigyaml v0.17.1 github.com/spf13/cobra v1.6.1 github.com/zmb3/spotify/v2 v2.3.1 + golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 + google.golang.org/api v0.30.0 modernc.org/sqlite v1.20.4 tuxpa.in/a/zlog v1.60.0 ) require ( + cloud.google.com/go v0.65.0 // indirect github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect github.com/aymanbagabas/go-osc52 v1.0.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/containerd/console v1.0.3 // indirect github.com/dustin/go-humanize v1.0.0 // indirect + github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/uuid v1.3.0 // indirect + github.com/googleapis/gax-go/v2 v2.0.5 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -41,14 +46,16 @@ require ( github.com/rs/zerolog v1.28.0 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + go.opencensus.io v0.22.4 // indirect golang.org/x/mod v0.3.0 // indirect - golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect golang.org/x/sys v0.1.0 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // indirect + google.golang.org/grpc v1.31.0 // indirect google.golang.org/protobuf v1.27.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/uint128 v1.2.0 // indirect diff --git a/go.sum b/go.sum index 892be5f..530afa1 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,7 @@ cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bP cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0 h1:Dg9iHVQfrhq82rUNu9ZxUDrJLaxFUe/HlCVaLyRruq8= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= @@ -82,6 +83,7 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x 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= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -131,6 +133,7 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -212,6 +215,7 @@ go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4 h1:LYy1Hy3MJdrCdMwwzxA/dRok4ejH+RwNGbuoD9fCjto= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -408,6 +412,7 @@ google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0 h1:yfrXXP61wVuLb0vBcG6qaOoIoqYEzOQS8jum51jkv2w= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -445,6 +450,7 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 h1:PDIOdWxZ8eRizhKa1AAvY53xsvLB1cWorMjslvY3VA8= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= @@ -457,6 +463,7 @@ google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8 google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0 h1:T7P4R73V3SSDPhH7WW7ATbfViLtmamH0DKrP3f9AuDI= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= diff --git a/src/cmd/youtube-link.go b/src/cmd/youtube-link.go new file mode 100644 index 0000000..3f32018 --- /dev/null +++ b/src/cmd/youtube-link.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// youtubeLinkCmd represents the youtube-link command +var youtubeLinkCmd = &cobra.Command{ + Use: "youtube-link", + Aliases: []string{"yl"}, + Short: "Print youtube link to currently playing song", + Long: `Print youtube link to currently playing song`, + Run: func(cmd *cobra.Command, args []string) { + link, err := commands.YoutubeLink(ctx) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + fmt.Print(link) + }, +} + +func init() { + rootCmd.AddCommand(youtubeLinkCmd) +} diff --git a/src/commands/commands.go b/src/commands/commands.go index 4f78026..f02e26b 100644 --- a/src/commands/commands.go +++ b/src/commands/commands.go @@ -18,6 +18,7 @@ import ( "git.asdf.cafe/abs3nt/gospt/src/auth" "git.asdf.cafe/abs3nt/gospt/src/cache" "git.asdf.cafe/abs3nt/gospt/src/gctx" + "git.asdf.cafe/abs3nt/gospt/src/youtube" "github.com/zmb3/spotify/v2" _ "modernc.org/sqlite" @@ -913,6 +914,15 @@ func (c *Commands) Link(ctx *gctx.Context) (string, error) { return state.Item.ExternalURLs["spotify"], nil } +func (c *Commands) YoutubeLink(ctx *gctx.Context) (string, error) { + state, err := c.Client().PlayerState(ctx) + if err != nil { + return "", err + } + link := youtube.Search(state.Item.Artists[0].Name + state.Item.Name) + return link, nil +} + func (c *Commands) LinkContext(ctx *gctx.Context) (string, error) { state, err := c.Client().PlayerState(ctx) if err != nil { diff --git a/src/tui/main.go b/src/tui/main.go index f7d1107..ca8bf96 100644 --- a/src/tui/main.go +++ b/src/tui/main.go @@ -125,6 +125,7 @@ func (m *mainModel) PlayRadio() { } func (m *mainModel) GoBack() (tea.Cmd, error) { + page = 1 switch m.mode { case Main: return tea.Quit, nil @@ -242,6 +243,7 @@ func (m *mainModel) CopyToClipboard() error { func (m *mainModel) SelectItem() error { switch m.mode { case Search: + page = 1 switch item := m.list.SelectedItem().(mainItem).SpotifyItem.(type) { case *spotify.FullArtistPage: m.mode = SearchArtists @@ -277,6 +279,7 @@ func (m *mainModel) SelectItem() error { m.list.ResetSelected() } case SearchArtists: + page = 1 m.mode = SearchArtist m.artist = m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimpleArtist) new_items, err := ArtistAlbumsView(m.ctx, m.artist.ID, m.commands) @@ -286,6 +289,7 @@ func (m *mainModel) SelectItem() error { m.list.SetItems(new_items) m.list.ResetSelected() case SearchArtist: + page = 1 m.mode = SearchArtistAlbum m.album = m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimpleAlbum) new_items, err := AlbumTracksView(m.ctx, m.album.ID, m.commands) @@ -295,6 +299,7 @@ func (m *mainModel) SelectItem() error { m.list.SetItems(new_items) m.list.ResetSelected() case SearchAlbums: + page = 1 m.mode = SearchAlbum m.album = m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimpleAlbum) new_items, err := AlbumTracksView(m.ctx, m.album.ID, m.commands) @@ -304,6 +309,7 @@ func (m *mainModel) SelectItem() error { m.list.SetItems(new_items) m.list.ResetSelected() case SearchPlaylists: + page = 1 m.mode = SearchPlaylist playlist := m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimplePlaylist) m.playlist = playlist @@ -314,6 +320,7 @@ func (m *mainModel) SelectItem() error { m.list.SetItems(new_items) m.list.ResetSelected() case Main: + page = 1 switch item := m.list.SelectedItem().(mainItem).SpotifyItem.(type) { case *spotify.FullArtistCursorPage: m.mode = Artists @@ -350,6 +357,7 @@ func (m *mainModel) SelectItem() error { m.list.ResetSelected() } case Albums: + page = 1 m.mode = Album m.album = m.list.SelectedItem().(mainItem).SpotifyItem.(spotify.SimpleAlbum) new_items, err := AlbumTracksView(m.ctx, m.album.ID, m.commands) @@ -504,7 +512,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { default: } // Call for more items if needed - if m.list.Paginator.Page == m.list.Paginator.TotalPages-2 && m.list.Cursor() == 0 { + if m.list.Paginator.Page == m.list.Paginator.TotalPages-1 && m.list.Cursor() == 0 { // if last request was still full request more if len(m.list.Items())%50 == 0 { go m.LoadMoreItems() diff --git a/src/youtube/youtube.go b/src/youtube/youtube.go new file mode 100644 index 0000000..2daa79b --- /dev/null +++ b/src/youtube/youtube.go @@ -0,0 +1,125 @@ +package youtube + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/url" + "os" + "os/user" + "path/filepath" + + "golang.org/x/net/context" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "google.golang.org/api/option" + "google.golang.org/api/youtube/v3" +) + +// getClient uses a Context and Config to retrieve a Token +// then generate a Client. It returns the generated Client. +func getClient(ctx context.Context, config *oauth2.Config) *http.Client { + cacheFile, err := tokenCacheFile() + if err != nil { + log.Fatalf("Unable to get path to cached credential file. %v", err) + } + tok, err := tokenFromFile(cacheFile) + if err != nil { + tok = getTokenFromWeb(config) + saveToken(cacheFile, tok) + } + return config.Client(ctx, tok) +} + +// getTokenFromWeb uses Config to request a Token. +// It returns the retrieved Token. +func getTokenFromWeb(config *oauth2.Config) *oauth2.Token { + authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline) + fmt.Printf("Go to the following link in your browser then type the "+ + "authorization code: \n%v\n", authURL) + + var code string + if _, err := fmt.Scan(&code); err != nil { + log.Fatalf("Unable to read authorization code %v", err) + } + + tok, err := config.Exchange(context.Background(), code) + if err != nil { + log.Fatalf("Unable to retrieve token from web %v", err) + } + return tok +} + +// tokenCacheFile generates credential file path/filename. +// It returns the generated credential path/filename. +func tokenCacheFile() (string, error) { + usr, err := user.Current() + if err != nil { + return "", err + } + tokenCacheDir := filepath.Join(usr.HomeDir, ".credentials") + err = os.MkdirAll(tokenCacheDir, 0o700) + if err != nil { + return "", err + } + return filepath.Join(tokenCacheDir, + url.QueryEscape("youtube-go-quickstart.json")), err +} + +// tokenFromFile retrieves a Token from a given file path. +// It returns the retrieved Token and any read error encountered. +func tokenFromFile(file string) (*oauth2.Token, error) { + f, err := os.Open(file) + handleError(err, "Error opening file") + t := &oauth2.Token{} + err = json.NewDecoder(f).Decode(t) + defer f.Close() + return t, err +} + +// saveToken uses a file path to create a file and store the +// token in it. +func saveToken(file string, token *oauth2.Token) { + fmt.Printf("Saving credential file to: %s\n", file) + f, err := os.OpenFile(file, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + log.Fatalf("Unable to cache oauth token: %v", err) + } + defer f.Close() + err = json.NewEncoder(f).Encode(token) + handleError(err, "Error encoding token") +} + +func handleError(err error, message string) { + if message == "" { + message = "Error making API call" + } + if err != nil { + log.Fatalf(message+": %v", err.Error()) + } +} + +func Search(query string) string { + ctx := context.Background() + + confDir, _ := os.UserConfigDir() + b, err := os.ReadFile(filepath.Join(confDir, "gospt", "client_secret.json")) + if err != nil { + log.Fatalf("Unable to read client secret file: %v", err) + } + + config, err := google.ConfigFromJSON(b, youtube.YoutubeReadonlyScope) + if err != nil { + log.Fatalf("Unable to parse client secret file to config: %v", err) + } + client := getClient(ctx, config) + service, err := youtube.NewService(ctx, option.WithHTTPClient(client)) + + handleError(err, "Error creating YouTube client") + call := service.Search.List([]string{"snippet"}) + call.Q(query) + response, err := call.Do() + handleError(err, "") + return fmt.Sprintf("https://www.youtube.com/watch?v=%s", response.Items[0].Id.VideoId) +}