Repository: goulinkh/podcast-cli Branch: master Commit: 26832a448dff Files: 19 Total size: 36.1 KB Directory structure: gitextract_b8au3yeh/ ├── .gitignore ├── .vscode/ │ └── launch.json ├── README.md ├── audio-player/ │ └── player.go ├── config/ │ └── main.go ├── go.mod ├── go.sum ├── itunes-api/ │ ├── episodes.go │ ├── genres.go │ └── podcasts.go ├── main.go ├── rss/ │ └── parser.go └── ui/ ├── Podcasts.go ├── audio_player.go ├── commands.go ├── episodes.go ├── genres.go ├── page.go └── sub_genres.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib # binary podcast-cli* main* !main.go # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ # IDE .idea/ # Project .cache/ # Vim *.swp *.swo *.swn *.un~ ================================================ FILE: .vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Launch file", "type": "go", "request": "launch", "mode": "auto", "program": "${workspaceFolder}" } ] } ================================================ FILE: README.md ================================================

podcast-cli

___ Top-like interface for listening to podcasts `podcast-cli` lets you play your favourite podcasts from the terminal:

podcast-cli

`podcast-cli` is entirely built with Go, you can run it on `Linux`, `Mac OS` and `Windows`. ## Install Fetch the [latest release](https://github.com/goulinkh/podcast-cli/releases) #### Linux ```bash sudo wget https://github.com/goulinkh/podcast-cli/releases/download/1.3.1/podcast-cli-1.3.1-linux-amd64 -O /usr/local/bin/podcast-cli sudo chmod +x /usr/local/bin/podcast-cli ``` #### OS X ```bash sudo curl -Lo /usr/local/bin/podcast-cli https://github.com/goulinkh/podcast-cli/releases/download/1.3.1/podcast-cli-1.3.1-darwin-amd64 sudo chmod +x /usr/local/bin/podcast-cli ``` ## Usage `podcast-cli` requires no arguments and uses your default internet settings to access the internet. ### Options | Options | Description | | ------------------------ | ------------------------------------------- | | `-h or --help` | Print help information | | `-s or --search ` | List podcasts that matches the search query | | `-r or --rss ` | Custom podcast rss url source | | `-o or --offset ` | Play episode number | ### Keybindings | Key | Action | | ---------- | -------- | | `Enter` | Select | | `p, Space` | Pause | | `Esc` | Back | | `Right` | +10s | | `Left` | -10s | | `u` | Slowdown | | `d` | Speedup | | `q` | Exit | ## Issues * Unable to get audio length of a remote content, I have to download the audio file before playing it ================================================ FILE: audio-player/player.go ================================================ package audioplayer import ( "errors" "fmt" "io/ioutil" "net/http" "net/url" "os" "path" "strings" "time" "github.com/faiface/beep" "github.com/faiface/beep/effects" "github.com/faiface/beep/mp3" "github.com/faiface/beep/speaker" "github.com/faiface/beep/wav" "github.com/goulinkh/podcast-cli/config" itunesapi "github.com/goulinkh/podcast-cli/itunes-api" ) type AudioPlayer struct { Streamer beep.StreamSeekCloser Format beep.Format } var ( MainCtrl *beep.Ctrl Volume *effects.Volume resampler *beep.Resampler Streamer beep.StreamSeekCloser Format beep.Format ) func init() { Volume = &effects.Volume{Base: 2} } func fetchContent(URL string, filepath string, directory string) error { _, err := ioutil.ReadFile(filepath) if err == nil { return nil } response, err := http.Get(URL) if err != nil { return err } audio, err := ioutil.ReadAll(response.Body) if err != nil { return err } os.MkdirAll(directory, 0755) // download content if not in .cache err = ioutil.WriteFile(filepath, audio, 0755) if err != nil { return err } return nil } // PlaySound play the given audio url, supported Formats: mp3, wav func PlaySound(e *itunesapi.Episode) error { if Streamer != nil { speaker.Lock() Streamer.Close() speaker.Unlock() } URL := e.AudioURL filename := fmt.Sprintf("%s.mp3", e.Id) directory := config.CachePath filename = url.PathEscape(path.Clean(strings.ReplaceAll(filename, ":", ""))) filepath := path.Join(directory, filename) file, err := os.Open(filepath) if err != nil { err = fetchContent(URL, filepath, directory) if err != nil { return err } } file, err = os.Open(filepath) if err != nil { return err } Streamer, Format, err = mp3.Decode(file) if err != nil { Streamer, Format, err = wav.Decode(file) } if err != nil { return errors.New("Unsupported audio format") } sr := Format.SampleRate * 2 speaker.Init(sr, sr.N(time.Millisecond*500)) streamer := beep.Resample(4, Format.SampleRate, sr, Streamer) MainCtrl = &beep.Ctrl{Streamer: streamer} resampler = beep.ResampleRatio(4, 1, MainCtrl) Volume = &effects.Volume{Streamer: resampler, Base: 2} speaker.Play(Volume) e.DurationInMilliseconds = int(float32(Streamer.Len())/float32(Format.SampleRate)) * 1000 return nil } func PauseSong(state bool) { speaker.Lock() MainCtrl.Paused = state speaker.Unlock() } func IncreaseSpeed() { speed := resampler.Ratio() * 1.100000e+000 if speed >= 1.800000e+000 { return } speaker.Lock() resampler.SetRatio(speed) speaker.Unlock() } func DecreaseSpeed() { speed := resampler.Ratio() * 0.900000e+000 if speed <= 1.000000e+000 { return } speaker.Lock() resampler.SetRatio(speed) speaker.Unlock() } func Seek(pos int) error { if MainCtrl != nil { speaker.Lock() err := Streamer.Seek(Format.SampleRate.N(time.Second) * pos) speaker.Unlock() return err } return nil } func SetVolume(percent int) { if percent > 100 { return } if percent == 0 { Volume.Silent = true } else { Volume.Silent = false Volume.Volume = -float64(100-percent) / 100.0 * 5 } } func Position() int { return int(Format.SampleRate.D(Streamer.Position()).Round(time.Second).Seconds()) } ================================================ FILE: config/main.go ================================================ package config import ( "os" "path" ) var CachePath = path.Join(os.TempDir(), "podcast-cli/") ================================================ FILE: go.mod ================================================ module github.com/goulinkh/podcast-cli go 1.14 require ( github.com/PuerkitoBio/goquery v1.5.1 github.com/akamensky/argparse v1.2.1 // indirect github.com/faiface/beep v1.0.2 github.com/gizak/termui/v3 v3.1.0 github.com/tidwall/gjson v1.6.0 ) ================================================ FILE: go.sum ================================================ github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE= github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/akamensky/argparse v1.2.1 h1:YMYF1VMku+dnz7TVTJpYhsCXHSYCVMAIcKaBbjwbvZo= github.com/akamensky/argparse v1.2.1/go.mod h1:S5kwC7IuDcEr5VeXtGPRVZ5o/FdhcMlQz4IZQuw64xA= github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/faiface/beep v1.0.2 h1:UB5DiRNmA4erfUYnHbgU4UB6DlBOrsdEFRtcc8sCkdQ= github.com/faiface/beep v1.0.2/go.mod h1:1yLb5yRdHMsovYYWVqYLioXkVuziCSITW1oarTeduQM= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell v1.1.1/go.mod h1:K1udHkiR3cOtlpKG5tZPD5XxrF7v2y7lDq7Whcj+xkQ= github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= github.com/gopherjs/gopherjs v0.0.0-20180628210949-0892b62f0d9f/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20180825215210-0210a2f0f73c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherwasm v0.1.1/go.mod h1:kx4n9a+MzHH0BJJhvlsQ65hqLFXDO/m256AsaDPQ+/4= github.com/gopherjs/gopherwasm v1.0.0/go.mod h1:SkZ8z7CWBz5VXbhJel8TxCmAcsQqzgWGR/8nMhyhZSI= github.com/hajimehoshi/go-mp3 v0.1.1 h1:Y33fAdTma70fkrxnc9u50Uq0lV6eZ+bkAlssdMmCwUc= github.com/hajimehoshi/go-mp3 v0.1.1/go.mod h1:4i+c5pDNKDrxl1iu9iG90/+fhP37lio6gNhjCx9WBJw= github.com/hajimehoshi/oto v0.1.1/go.mod h1:hUiLWeBQnbDu4pZsAhOnGqMI1ZGibS6e2qhQdfpwz04= github.com/hajimehoshi/oto v0.3.1 h1:cpf/uIv4Q0oc5uf9loQn7PIehv+mZerh+0KKma6gzMk= github.com/hajimehoshi/oto v0.3.1/go.mod h1:e9eTLBB9iZto045HLbzfHJIc+jP3xaKrjZTghvb6fdM= github.com/jfreymuth/oggvorbis v1.0.0/go.mod h1:abe6F9QRjuU9l+2jek3gj46lu40N4qlYxh2grqkLEDM= github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= github.com/lucasb-eyer/go-colorful v0.0.0-20181028223441-12d3b2882a08/go.mod h1:NXg0ArsFk0Y01623LgUqoqcouGDB+PwCCQlrwrG6xJ4= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mewkiz/flac v1.0.5/go.mod h1:EHZNU32dMF6alpurYyKHDLYpW1lYpBZ5WrXi/VuNIGs= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/tidwall/gjson v1.6.0 h1:9VEQWz6LLMUsUl6PueE49ir4Ka6CzLymOAZDxpFsTDc= github.com/tidwall/gjson v1.6.0/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc= github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/exp v0.0.0-20180710024300-14dda7b62fcd/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= golang.org/x/mobile v0.0.0-20180806140643-507816974b79/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0/go.mod h1:OdE7CF6DbADk7lN8LIKRzRJTTZXIjtWgA5THM5lhBAw= ================================================ FILE: itunes-api/episodes.go ================================================ package itunesapi type Episode struct { Id string `json:"id"` Artwork string `json:"artwork"` Title string `json:"title"` AudioURL string `json:"audiourl"` ReleaseDate string `json:"releasedate"` DurationInMilliseconds int `json:"duratioInMilliseconds"` Description string `json:"description"` } ================================================ FILE: itunes-api/genres.go ================================================ package itunesapi import ( "net/http" "regexp" "github.com/PuerkitoBio/goquery" ) const applePodcastsMainPage = "https://podcasts.apple.com/genre/podcasts/id26" var ( authorization string ) type Genre struct { Text string `json:"text"` URL string `json:"url"` Id string `json:"url"` SubGenre []*Genre `json:"sub-genres"` } func getGenreId(url string) string { idRegExp := regexp.MustCompile(`\d+$`) return idRegExp.FindString(url) } func GetGenres() ([]*Genre, error) { resp, err := http.Get(applePodcastsMainPage) if err != nil { return nil, err } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { return nil, err } genres := make([]*Genre, 0) doc.Find(".list.column > li").Each(func(i int, s *goquery.Selection) { genreSelection := s.Find(".top-level-genre") href, exists := genreSelection.Attr("href") if !exists { return } genre := &Genre{Text: genreSelection.Text(), URL: href, SubGenre: make([]*Genre, 0), Id: getGenreId(href)} s.Find(".top-level-subgenres > li > a").Each(func(i int, s *goquery.Selection) { href, exists := genreSelection.Attr("href") if !exists { return } genre.SubGenre = append(genre.SubGenre, &Genre{Text: s.Text(), URL: href, Id: getGenreId(href)}) }) genres = append(genres, genre) }) return genres, nil } ================================================ FILE: itunes-api/podcasts.go ================================================ package itunesapi import ( "errors" "fmt" "io/ioutil" "net/http" "regexp" "github.com/tidwall/gjson" ) type Podcast struct { Title string `json:"title"` URL string `json:"url` Id string `json:"id"` Description string `json:"description"` Author string `json:"author"` } func FindPodcasts(query string) ([]*Podcast, error) { authorization, err := getAuthorization() if err != nil { return nil, err } request, err := http.NewRequest("GET", fmt.Sprintf("https://itunes.apple.com/search?country=us&entity=podcast&term=%s", query), nil) if err != nil { return nil, err } request.Header.Add("Authorization", authorization) resp, err := http.DefaultClient.Do(request) if err != nil { return nil, err } data, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, err } podcastsJSON := gjson.Get(string(data), "results").Array() podcasts := make([]*Podcast, 0) for _, podcast := range podcastsJSON { if podcast.Get("kind").String() == "podcast" && podcast.Get("wrapperType").String() == "track" { podcasts = append(podcasts, &Podcast{ Author: podcast.Get("artistName").String(), Description: "", Id: podcast.Get("trackId").String(), Title: podcast.Get("collectionName").String(), URL: regexp.MustCompile(`\?.*$`).ReplaceAllString(podcast.Get("collectionViewUrl").String(), ""), }) } } return podcasts, nil } func (p *Podcast) GetEpisodes() ([]*Episode, error) { req, err := http.NewRequest("GET", fmt.Sprintf("https://amp-api.podcasts.apple.com/v1/catalog/us/podcasts/%s/episodes?offset=0&limit=300", p.Id), nil) if err != nil { return nil, err } authorization, err := getAuthorization() if err != nil { return nil, err } req.Header.Add("Authorization", authorization) response, err := http.DefaultClient.Do(req) if err != nil { return nil, err } data, err := ioutil.ReadAll(response.Body) if err != nil { return nil, err } episodesJSON := gjson.Get(string(data), "data").Array() episodes := make([]*Episode, 0) for _, episode := range episodesJSON { if episode.Get(`attributes.mediaKind`).String() == "audio" { episodes = append(episodes, &Episode{ Id: episode.Get(`id`).String(), Artwork: episode.Get(`attributes.artwork.url`).String(), Title: episode.Get(`attributes.name`).String(), AudioURL: episode.Get(`attributes.assetUrl`).String(), ReleaseDate: episode.Get(`attributes.releaseDateTime`).String(), DurationInMilliseconds: int(episode.Get(`attributes.durationInMilliseconds`).Int()), Description: episode.Get(`attributes.description.standard`).String(), }) } } return episodes, nil } func (g *Genre) GetPodcasts() ([]*Podcast, error) { request, err := http.NewRequest("GET", "https://amp-api.podcasts.apple.com/v1/catalog/us/charts?types=podcasts&limit=200&genre="+g.Id, nil) if err != nil { return nil, err } authorization, err := getAuthorization() if err != nil { return nil, err } request.Header.Add("Authorization", authorization) resp, err := http.DefaultClient.Do(request) if err != nil { return nil, err } data, err := ioutil.ReadAll(resp.Body) podcastsJSON := gjson.Get(string(data), "results.podcasts.0.data").Array() podcasts := make([]*Podcast, len(podcastsJSON)) for i, podcast := range podcastsJSON { podcasts[i] = &Podcast{ Id: podcast.Get("id").String(), Description: podcast.Get("attributes.description.standard").String(), Title: podcast.Get("attributes.name").String(), URL: "https://amp-api.podcasts.apple.com" + podcast.Get("href").String(), Author: podcast.Get("attributes.artistName").String(), } } return podcasts, nil } func getAuthorization() (string, error) { if authorization != "" { return authorization, nil } authRegEx := regexp.MustCompile("privateKeyPath.+token%22%3A%22(?P.*?)%22%7D%2C%22") resp, err := http.Get("https://podcasts.apple.com/us/podcast/the-joe-rogan-experience/id360084272") if err != nil { return "", err } data, err := ioutil.ReadAll(resp.Body) if err != nil { return "", err } match := authRegEx.FindStringSubmatch(string(data)) if len(match) != 2 { return "", errors.New("Authorization access token is not found") } authorization = "Bearer " + match[1] return authorization, nil } ================================================ FILE: main.go ================================================ package main import ( "log" "os" "github.com/akamensky/argparse" ui "github.com/gizak/termui/v3" itunesapi "github.com/goulinkh/podcast-cli/itunes-api" "github.com/goulinkh/podcast-cli/rss" podcastcliui "github.com/goulinkh/podcast-cli/ui" ) func main() { parser := argparse.NewParser("podcast-cli", "CLI podcast player") podcastSearchQuery := parser.String("s", "search", &argparse.Options{Required: false, Help: "your podcast's name"}) rssUrl := parser.String("r", "rss", &argparse.Options{Required: false, Help: "custom podcast rss source"}) offset := parser.Int("o", "offset", &argparse.Options{Required: false, Help: "play episode number"}) err := parser.Parse(os.Args) if err != nil { log.Fatalln("Error:", parser.Usage(err)) return } err = podcastcliui.InitUI() if err != nil { log.Fatal(err) } if podcastSearchQuery != nil && *podcastSearchQuery != "" { podcasts, err := itunesapi.FindPodcasts(*podcastSearchQuery) if err != nil { log.Fatalln("Error: Failed to search for podcasts") } podcastsWidget := &podcastcliui.PodcastsUI{Podcasts: podcasts} podcastsWidget.InitComponents() podcastcliui.Show(podcastsWidget) } else if rssUrl != nil && *rssUrl != "" { episodes, err := rss.ParseEpisodes(*rssUrl) if err != nil { log.Fatalln("Error: Failed to get episodes from the url: " + *rssUrl) } episodesWidget := &podcastcliui.EpisodesUI{Episodes: episodes} episodesWidget.InitComponents() podcastcliui.Show(episodesWidget) if offset != nil { episodesWidget.Play(*offset) } } else { genres, err := itunesapi.GetGenres() if err != nil { log.Fatal(err) } genreWidget := &podcastcliui.GenresUI{ Genres: genres, } genreWidget.InitComponents() podcastcliui.Show(genreWidget) } uiEvents := ui.PollEvents() for { select { case e := <-uiEvents: cmd, err := podcastcliui.HandleKeyEvent(&e) if err != nil { log.Fatal(err) } if cmd == podcastcliui.Exit { return } } } } ================================================ FILE: rss/parser.go ================================================ package rss import ( "encoding/xml" "io/ioutil" "net/http" "strconv" itunesapi "github.com/goulinkh/podcast-cli/itunes-api" ) type Rss struct { Channel struct { Item []struct { Title string `xml:"title"` PubDate string `xml:"pubDate"` Author string `xml:"author"` Description string `xml:"description"` Image struct { Href string `xml:"href,attr"` } `xml:"image"` Enclosure struct { URL string `xml:"url,attr"` Length int `xml:"length,attr"` Type string `xml:"type,attr"` } `xml:"enclosure"` Duration int `xml:"duration"` } `xml:"item"` } `xml:"channel"` } func ParseEpisodes(rssUrl string) ([]*itunesapi.Episode, error) { resp, err := http.Get(rssUrl) if err != nil { return nil, err } rss, err := ioutil.ReadAll(resp.Body) var podcast Rss err = xml.Unmarshal(rss, &podcast) episodes := make([]*itunesapi.Episode, len(podcast.Channel.Item)) for i, e := range podcast.Channel.Item { episodes[i] = &itunesapi.Episode{ Artwork: e.Image.Href, AudioURL: e.Enclosure.URL, Description: e.Description, DurationInMilliseconds: e.Duration * 1000, Id: strconv.Itoa(i), ReleaseDate: e.PubDate, Title: e.Title, } } return episodes, nil } ================================================ FILE: ui/Podcasts.go ================================================ package ui import ( "errors" "fmt" "strings" ui "github.com/gizak/termui/v3" "github.com/gizak/termui/v3/widgets" itunesapi "github.com/goulinkh/podcast-cli/itunes-api" ) type PodcastsUI struct { Podcasts []*itunesapi.Podcast listWidget *widgets.List detailsWidget *widgets.Paragraph gridWidget *ui.Grid } func (p *PodcastsUI) InitComponents() error { p.initListWidget() p.initDetailsWidget() err := p.initGridWidget() return err } func (p *PodcastsUI) MainUI() *ui.Grid { return p.gridWidget } func (p *PodcastsUI) HandleEvent(event *ui.Event) (Command, error) { switch event.ID { case "j", "": p.listWidget.ScrollDown() p.updateDetailsWidget() case "k", "": p.listWidget.ScrollUp() p.updateDetailsWidget() case "": episodes, err := p.Podcasts[p.listWidget.SelectedRow].GetEpisodes() if err != nil { return Nothing, err } episodesUI := &EpisodesUI{Episodes: episodes} err = episodesUI.InitComponents() if err != nil { return Nothing, err } Show(episodesUI) } return Nothing, nil } func (p *PodcastsUI) initGridWidget() error { if p.listWidget == nil { return errors.New("Uninitialized podcasts list widget") } if p.detailsWidget == nil { return errors.New("Uninitialized details widget") } p.gridWidget = ui.NewGrid() termWidth, termHeight := ui.TerminalDimensions() p.gridWidget.SetRect(0, 0, termWidth, termHeight-1) p.gridWidget.Set( ui.NewRow(1.0, ui.NewCol(1.0/2, p.listWidget), ui.NewCol(1.0/2, ui.NewRow(6.0/10, p.detailsWidget), ui.NewRow(4.0/10, audioPlayerWidget.MainUI())))) return nil } func (p *PodcastsUI) initListWidget() { p.listWidget = widgets.NewList() p.listWidget.Title = "Podcasts List" p.listWidget.TextStyle.Fg = FgColor p.listWidget.SelectedRowStyle.Fg = ui.ColorBlack p.listWidget.SelectedRowStyle.Bg = AccentColor p.listWidget.BorderStyle.Fg = AccentColor p.listWidget.Rows = make([]string, len(p.Podcasts)) for i, podcast := range p.Podcasts { p.listWidget.Rows[i] = podcast.Title } } func (p *PodcastsUI) initDetailsWidget() { p.detailsWidget = widgets.NewParagraph() p.detailsWidget.Title = "Details" p.detailsWidget.BorderStyle.Fg = AccentColor p.detailsWidget.BorderLeft = false p.detailsWidget.BorderBottom = false p.updateDetailsWidget() } func (p *PodcastsUI) updateDetailsWidget() { if p.Podcasts == nil || len(p.Podcasts) == 0 { return } currentPodcast := p.Podcasts[p.listWidget.SelectedRow] title := fmt.Sprintf("[Title](fg:magenta) %s", currentPodcast.Title) description := fmt.Sprintf("[Description](fg:magenta) %s", currentPodcast.Description) author := fmt.Sprintf("[Author](fg:magenta) %s", currentPodcast.Author) p.detailsWidget.Text = strings.Join([]string{title, description, author}, "\n") } ================================================ FILE: ui/audio_player.go ================================================ package ui import ( "fmt" "time" ui "github.com/gizak/termui/v3" "github.com/gizak/termui/v3/widgets" audioplayer "github.com/goulinkh/podcast-cli/audio-player" itunesapi "github.com/goulinkh/podcast-cli/itunes-api" ) type AudioPlayerWidget struct { playlist []*itunesapi.Episode nowPlaying *itunesapi.Episode nowPlayingIndex int paused bool audioPositionWidget *widgets.Gauge playerStatusWidget *widgets.Paragraph grid *ui.Grid playSpeed float32 } func (ap *AudioPlayerWidget) InitComponents() { ap.paused = true ap.initAudipPositionWidget() ap.initPlayerStatusWidget() ap.initGrid() } func (ap *AudioPlayerWidget) MainUI() *ui.Grid { return ap.grid } func (ap *AudioPlayerWidget) HandleEvent(e *ui.Event) (Command, error) { switch e.ID { case "p", "": ap.Pause() case "": if audioplayer.MainCtrl != nil && ap.nowPlaying != nil { position := audioplayer.Position() + 10 if position < ap.nowPlaying.DurationInMilliseconds/1000 { audioplayer.Seek(position) } } case "": if audioplayer.MainCtrl != nil && ap.nowPlaying != nil { position := audioplayer.Position() - 10 if position > 0 { audioplayer.Seek(position) } } case "u": if audioplayer.MainCtrl != nil && ap.nowPlaying != nil { audioplayer.IncreaseSpeed() } case "d": if audioplayer.MainCtrl != nil && ap.nowPlaying != nil { audioplayer.DecreaseSpeed() } } return Nothing, nil } func (ap *AudioPlayerWidget) Play(playlist []*itunesapi.Episode, index int) { e := playlist[index] if e == nil || (ap.nowPlaying != nil && ap.nowPlaying.Id == e.Id) { return } ap.playerStatusWidget.Title = "Downloading audio ..." RefreshUI() go func() { ap.nowPlaying = e ap.playlist = playlist ap.nowPlayingIndex = index ap.paused = false ap.playerStatusWidget.Title = "Now Playing" ap.playAudio(ap.nowPlaying) for { select { case <-time.After(time.Millisecond * 100): if ap.paused { ap.audioPositionWidget.Title = "Paused" } else { ap.playerStatusWidget.Text = ap.nowPlaying.Title position := audioplayer.Position() ap.audioPositionWidget.Title = "Running" ap.audioPositionWidget.Label = fmt.Sprintf("%d:%d", position/60, position%60) audioDuration := e.DurationInMilliseconds / 1000 if audioDuration > 0 { ap.audioPositionWidget.Percent = (position * 100) / audioDuration } if ap.audioPositionWidget.Percent == 100 { ap.Play(playlist, index+1) return } } RefreshUI() } } }() return } func (ap *AudioPlayerWidget) playAudio(e *itunesapi.Episode) { if err := audioplayer.PlaySound(e); err != nil { ap.playerStatusWidget.Title = "Failed to play audio" RefreshUI() } } func (ap *AudioPlayerWidget) Pause() { ap.paused = !ap.paused audioplayer.PauseSong(ap.paused) } func (ap *AudioPlayerWidget) initAudipPositionWidget() { ap.audioPositionWidget = widgets.NewGauge() ap.audioPositionWidget.BorderLeft = false ap.audioPositionWidget.BarColor = AccentColor ap.audioPositionWidget.BorderStyle.Fg = AccentColor } func (ap *AudioPlayerWidget) initPlayerStatusWidget() { ap.playerStatusWidget = widgets.NewParagraph() ap.playerStatusWidget.BorderLeft = false ap.playerStatusWidget.BorderBottom = false ap.playerStatusWidget.TextStyle.Fg = AccentColor ap.playerStatusWidget.Title = "Now Playing" ap.playerStatusWidget.TitleStyle.Fg = FgColor ap.playerStatusWidget.BorderStyle.Fg = AccentColor } func (ap *AudioPlayerWidget) initGrid() { ap.grid = ui.NewGrid() ap.grid.Border = false ap.grid.Set( ui.NewRow(1.0, ui.NewRow(1.0/2, ap.playerStatusWidget), ui.NewRow(1.0/2, ap.audioPositionWidget), ), ) } ================================================ FILE: ui/commands.go ================================================ package ui type Command int const ( Nothing Command = 0 Exit Command = 1 ) ================================================ FILE: ui/episodes.go ================================================ package ui import ( "errors" "fmt" "strings" ui "github.com/gizak/termui/v3" "github.com/gizak/termui/v3/widgets" itunesapi "github.com/goulinkh/podcast-cli/itunes-api" ) type EpisodesUI struct { Episodes []*itunesapi.Episode listWidget *widgets.List detailsWidget *widgets.Paragraph gridWidget *ui.Grid } func (e *EpisodesUI) InitComponents() error { e.initListWidget() e.initDetailsWidget() err := e.initGridWidget() return err } func (e *EpisodesUI) MainUI() *ui.Grid { return e.gridWidget } func (e *EpisodesUI) HandleEvent(event *ui.Event) (Command, error) { switch event.ID { case "j", "": e.listWidget.ScrollDown() e.updateDetailsWidget() case "k", "": e.listWidget.ScrollUp() e.updateDetailsWidget() case "": audioPlayerWidget.Play(e.Episodes, e.listWidget.SelectedRow) } return Nothing, nil } func (e *EpisodesUI) Play(index int) { audioPlayerWidget.Play(e.Episodes, index) } func (e *EpisodesUI) initGridWidget() error { if e.listWidget == nil { return errors.New("List widget is not initialized") } if e.detailsWidget == nil { return errors.New("Details widget is not initialized") } e.gridWidget = ui.NewGrid() termWidth, termHeight := ui.TerminalDimensions() e.gridWidget.SetRect(0, 0, termWidth, termHeight-1) e.gridWidget.Set( ui.NewRow(1.0, ui.NewCol(1.0/2, e.listWidget), ui.NewCol(1.0/2, ui.NewRow(6.0/10, e.detailsWidget), ui.NewRow(4.0/10, audioPlayerWidget.MainUI())))) return nil } func (e *EpisodesUI) initListWidget() { e.listWidget = widgets.NewList() e.listWidget.Title = "Episodes" e.listWidget.SelectedRowStyle.Modifier = ui.ModifierClear e.listWidget.TextStyle.Fg = FgColor e.listWidget.SelectedRowStyle.Fg = ui.ColorBlack e.listWidget.SelectedRowStyle.Bg = AccentColor e.listWidget.BorderStyle.Fg = AccentColor e.listWidget.Rows = make([]string, len(e.Episodes)) for i, episode := range e.Episodes { e.listWidget.Rows[i] = episode.Title } } func (e *EpisodesUI) initDetailsWidget() { e.detailsWidget = widgets.NewParagraph() e.detailsWidget.Title = "Details" e.detailsWidget.BorderStyle.Fg = AccentColor e.detailsWidget.BorderLeft = false e.detailsWidget.BorderBottom = false e.updateDetailsWidget() } func (e *EpisodesUI) updateDetailsWidget() { if e.Episodes == nil || len(e.Episodes) == 0 { return } currentEpisode := e.Episodes[e.listWidget.SelectedRow] title := fmt.Sprintf("[Title](fg:magenta) %s", currentEpisode.Title) description := fmt.Sprintf("[Description](fg:magenta) %s", currentEpisode.Description) date := fmt.Sprintf("[Release Date](fg:magenta) %s", currentEpisode.ReleaseDate) duration := fmt.Sprintf("[Duration](fg:magenta) %d min", currentEpisode.DurationInMilliseconds/60000) e.detailsWidget.Text = strings.Join([]string{title, duration, date, description}, "\n") } ================================================ FILE: ui/genres.go ================================================ package ui import ( "errors" ui "github.com/gizak/termui/v3" "github.com/gizak/termui/v3/widgets" itunesapi "github.com/goulinkh/podcast-cli/itunes-api" ) type GenresUI struct { Genres []*itunesapi.Genre gridWidget *ui.Grid listWidget *widgets.List } func (g *GenresUI) InitComponents() error { g.newGenresListWidget() err := g.newGridWidget() if err != nil { return err } return nil } func (g *GenresUI) MainUI() *ui.Grid { return g.gridWidget } func (g *GenresUI) HandleEvent(event *ui.Event) (Command, error) { switch event.ID { case "j", "": g.listWidget.ScrollDown() case "k", "": g.listWidget.ScrollUp() case "": subGenres := g.Genres[g.listWidget.SelectedRow].SubGenre var subGenreUI *SubGenresUI if subGenres == nil || len(subGenres) == 0 { subGenreUI = &SubGenresUI{Genres: []*itunesapi.Genre{g.Genres[g.listWidget.SelectedRow]}} } else { subGenreUI = &SubGenresUI{Genres: g.Genres[g.listWidget.SelectedRow].SubGenre} } subGenreUI.InitComponents() Show(subGenreUI) } return Nothing, nil } func (g *GenresUI) newGenresListWidget() error { g.listWidget = widgets.NewList() g.listWidget.Title = "Select a Genre" g.listWidget.TextStyle = ui.NewStyle(FgColor) g.listWidget.SelectedRowStyle.Fg = ui.ColorBlack g.listWidget.SelectedRowStyle.Bg = AccentColor g.listWidget.BorderStyle.Fg = AccentColor if g.Genres == nil { return errors.New("Missing Genres array") } g.listWidget.Rows = make([]string, len(g.Genres)) for i, genre := range g.Genres { g.listWidget.Rows[i] = genre.Text } return nil } func (g *GenresUI) newGridWidget() error { if g.listWidget == nil { return errors.New("Uninitialized genres list widget") } g.gridWidget = ui.NewGrid() termWidth, termHeight := ui.TerminalDimensions() g.gridWidget.SetRect(0, 0, termWidth, termHeight-1) placeholder := ui.NewBlock() placeholder.BorderBottom = false placeholder.BorderLeft = false placeholder.BorderStyle.Fg = AccentColor g.gridWidget.Set( ui.NewRow(1.0, ui.NewCol(1.0/2, g.listWidget), ui.NewCol(1.0/2, ui.NewRow(6.0/10, placeholder), ui.NewRow(4.0/10, audioPlayerWidget.MainUI())))) return nil } ================================================ FILE: ui/page.go ================================================ package ui import ( "errors" "fmt" ui "github.com/gizak/termui/v3" "github.com/gizak/termui/v3/widgets" ) var ( FgColor = ui.ColorWhite AccentColor = ui.ColorMagenta pagesHistory = make([]Page, 0) currentPage Page helpBarWidget *widgets.Paragraph audioPlayerWidget = &AudioPlayerWidget{} ) type Page interface { MainUI() *ui.Grid HandleEvent(*ui.Event) (Command, error) } func InitUI() error { if err := ui.Init(); err != nil { errors.New(fmt.Sprintf("failed to initialize the UI: %v", err)) } helpBarWidget = newHelpBarWidget() audioPlayerWidget.InitComponents() return nil } func Show(p Page) { if currentPage != nil { pagesHistory = append(pagesHistory, currentPage) } show(p) } func show(p Page) { currentPage = p RefreshUI() } func RefreshUI() { ui.Clear() ui.Render(currentPage.MainUI(), helpBarWidget) } func GoBack() { if len(pagesHistory) == 0 { return } previousPage := pagesHistory[len(pagesHistory)-1] pagesHistory = pagesHistory[:len(pagesHistory)-1] show(previousPage) } func HandleKeyEvent(e *ui.Event) (Command, error) { switch e.ID { case "q", "": ui.Close() return Exit, nil case "", ">", "": GoBack() RefreshUI() case "": payload := e.Payload.(ui.Resize) helpBarWidget.SetRect(0, payload.Height-1, payload.Width, payload.Height) currentPage.MainUI().SetRect(0, 0, payload.Width, payload.Height-1) RefreshUI() default: cmd, err := currentPage.HandleEvent(e) if err != nil { return cmd, err } cmd, err = audioPlayerWidget.HandleEvent(e) RefreshUI() return cmd, err } return Nothing, nil } func newHelpBarWidget() *widgets.Paragraph { helpBarWidget := widgets.NewParagraph() helpBarWidget.Text = "[ Enter ](fg:black)[Select](fg:black,bg:green) " + "[ p, Space ](fg:black)[Play/Pause](fg:black,bg:green) " + "[Esc ](fg:black)[Back](fg:black,bg:green) " + "[Right ](fg:black)[+10s](fg:black,bg:green) " + "[Left ](fg:black)[-10s](fg:black,bg:green) " + "[ d ](fg:black)[Slowdown](fg:black,bg:green)" + "[ u ](fg:black)[Speedup](fg:black,bg:green)" + "[ q ](fg:black)[Exit](fg:black,bg:green)" helpBarWidget.Border = false helpBarWidget.WrapText = true helpBarWidget.TextStyle = ui.Style{Modifier: ui.ModifierBold, Bg: ui.ColorWhite} termWidth, termHeight := ui.TerminalDimensions() helpBarWidget.SetRect(0, termHeight-1, termWidth, termHeight) return helpBarWidget } ================================================ FILE: ui/sub_genres.go ================================================ package ui import ( "errors" ui "github.com/gizak/termui/v3" "github.com/gizak/termui/v3/widgets" itunesapi "github.com/goulinkh/podcast-cli/itunes-api" ) type SubGenresUI struct { Genres []*itunesapi.Genre gridWidget *ui.Grid listWidget *widgets.List } func (g *SubGenresUI) InitComponents() error { g.newGenresListWidget() err := g.newGridWidget() if err != nil { return err } return nil } func (g *SubGenresUI) MainUI() *ui.Grid { return g.gridWidget } func (g *SubGenresUI) HandleEvent(event *ui.Event) (Command, error) { switch event.ID { case "j", "": g.listWidget.ScrollDown() case "k", "": g.listWidget.ScrollUp() case "": podcasts, err := g.Genres[g.listWidget.SelectedRow].GetPodcasts() if err != nil { return Nothing, err } podcastsUI := &PodcastsUI{Podcasts: podcasts} err = podcastsUI.InitComponents() if err != nil { return Nothing, err } Show(podcastsUI) } return Nothing, nil } func (g *SubGenresUI) newGenresListWidget() error { g.listWidget = widgets.NewList() g.listWidget.Title = "Select a Sub Genre" g.listWidget.TextStyle = ui.NewStyle(FgColor) g.listWidget.SelectedRowStyle.Fg = ui.ColorBlack g.listWidget.SelectedRowStyle.Bg = AccentColor g.listWidget.BorderStyle.Fg = AccentColor if g.Genres == nil { return errors.New("Missing Sub Genres array") } g.listWidget.Rows = make([]string, len(g.Genres)) for i, genre := range g.Genres { g.listWidget.Rows[i] = genre.Text } return nil } func (g *SubGenresUI) newGridWidget() error { if g.listWidget == nil { return errors.New("Uninitialized sub genres list widget") } g.gridWidget = ui.NewGrid() termWidth, termHeight := ui.TerminalDimensions() g.gridWidget.SetRect(0, 0, termWidth, termHeight-1) placeholder := ui.NewBlock() placeholder.BorderBottom = false placeholder.BorderLeft = false placeholder.BorderStyle.Fg = AccentColor g.gridWidget.Set( ui.NewRow(1.0, ui.NewCol(1.0/2, g.listWidget), ui.NewCol(1.0/2, ui.NewRow(6.0/10, placeholder), ui.NewRow(4.0/10, audioPlayerWidget.MainUI())))) return nil } func (g *SubGenresUI) refreshComponents() { g.newGenresListWidget() }