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
================================================

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

`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()
}