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
================================================
<p align="center"><img width="200px" src="/resources/img/logo.png" alt="podcast-cli"/></p>
___
Top-like interface for listening to podcasts
`podcast-cli` lets you play your favourite podcasts from the terminal:
<p align="center"><img src="/resources/img/demo.gif" alt="podcast-cli"/></p>
`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 <query>` | List podcasts that matches the search query |
| `-r or --rss <url>` | Custom podcast rss url source |
| `-o or --offset <episode number starting with 0>` | 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<Bearer>.*?)%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", "<Down>":
p.listWidget.ScrollDown()
p.updateDetailsWidget()
case "k", "<Up>":
p.listWidget.ScrollUp()
p.updateDetailsWidget()
case "<Enter>":
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", "<Space>":
ap.Pause()
case "<Right>":
if audioplayer.MainCtrl != nil && ap.nowPlaying != nil {
position := audioplayer.Position() + 10
if position < ap.nowPlaying.DurationInMilliseconds/1000 {
audioplayer.Seek(position)
}
}
case "<Left>":
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", "<Down>":
e.listWidget.ScrollDown()
e.updateDetailsWidget()
case "k", "<Up>":
e.listWidget.ScrollUp()
e.updateDetailsWidget()
case "<Enter>":
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", "<Down>":
g.listWidget.ScrollDown()
case "k", "<Up>":
g.listWidget.ScrollUp()
case "<Enter>":
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", "<C-c>":
ui.Close()
return Exit, nil
case "<Escape>", "<C-<Backspace>>", "<Backspace>":
GoBack()
RefreshUI()
case "<Resize>":
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", "<Down>":
g.listWidget.ScrollDown()
case "k", "<Up>":
g.listWidget.ScrollUp()
case "<Enter>":
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()
}
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
SYMBOL INDEX (74 symbols across 13 files)
FILE: audio-player/player.go
type AudioPlayer (line 23) | type AudioPlayer struct
function init (line 37) | func init() {
function fetchContent (line 41) | func fetchContent(URL string, filepath string, directory string) error {
function PlaySound (line 65) | func PlaySound(e *itunesapi.Episode) error {
function PauseSong (line 106) | func PauseSong(state bool) {
function IncreaseSpeed (line 112) | func IncreaseSpeed() {
function DecreaseSpeed (line 122) | func DecreaseSpeed() {
function Seek (line 132) | func Seek(pos int) error {
function SetVolume (line 142) | func SetVolume(percent int) {
function Position (line 155) | func Position() int {
FILE: itunes-api/episodes.go
type Episode (line 3) | type Episode struct
FILE: itunes-api/genres.go
constant applePodcastsMainPage (line 10) | applePodcastsMainPage = "https://podcasts.apple.com/genre/podcasts/id26"
type Genre (line 16) | type Genre struct
function getGenreId (line 23) | func getGenreId(url string) string {
function GetGenres (line 28) | func GetGenres() ([]*Genre, error) {
FILE: itunes-api/podcasts.go
type Podcast (line 13) | type Podcast struct
method GetEpisodes (line 57) | func (p *Podcast) GetEpisodes() ([]*Episode, error) {
function FindPodcasts (line 21) | func FindPodcasts(query string) ([]*Podcast, error) {
method GetPodcasts (line 97) | func (g *Genre) GetPodcasts() ([]*Podcast, error) {
function getAuthorization (line 127) | func getAuthorization() (string, error) {
FILE: main.go
function main (line 15) | func main() {
FILE: rss/parser.go
type Rss (line 12) | type Rss struct
function ParseEpisodes (line 32) | func ParseEpisodes(rssUrl string) ([]*itunesapi.Episode, error) {
FILE: ui/Podcasts.go
type PodcastsUI (line 13) | type PodcastsUI struct
method InitComponents (line 20) | func (p *PodcastsUI) InitComponents() error {
method MainUI (line 26) | func (p *PodcastsUI) MainUI() *ui.Grid {
method HandleEvent (line 29) | func (p *PodcastsUI) HandleEvent(event *ui.Event) (Command, error) {
method initGridWidget (line 52) | func (p *PodcastsUI) initGridWidget() error {
method initListWidget (line 70) | func (p *PodcastsUI) initListWidget() {
method initDetailsWidget (line 82) | func (p *PodcastsUI) initDetailsWidget() {
method updateDetailsWidget (line 90) | func (p *PodcastsUI) updateDetailsWidget() {
FILE: ui/audio_player.go
type AudioPlayerWidget (line 13) | type AudioPlayerWidget struct
method InitComponents (line 24) | func (ap *AudioPlayerWidget) InitComponents() {
method MainUI (line 31) | func (ap *AudioPlayerWidget) MainUI() *ui.Grid {
method HandleEvent (line 35) | func (ap *AudioPlayerWidget) HandleEvent(e *ui.Event) (Command, error) {
method Play (line 68) | func (ap *AudioPlayerWidget) Play(playlist []*itunesapi.Episode, index...
method playAudio (line 108) | func (ap *AudioPlayerWidget) playAudio(e *itunesapi.Episode) {
method Pause (line 115) | func (ap *AudioPlayerWidget) Pause() {
method initAudipPositionWidget (line 120) | func (ap *AudioPlayerWidget) initAudipPositionWidget() {
method initPlayerStatusWidget (line 126) | func (ap *AudioPlayerWidget) initPlayerStatusWidget() {
method initGrid (line 135) | func (ap *AudioPlayerWidget) initGrid() {
FILE: ui/commands.go
type Command (line 3) | type Command
constant Nothing (line 6) | Nothing Command = 0
constant Exit (line 7) | Exit Command = 1
FILE: ui/episodes.go
type EpisodesUI (line 13) | type EpisodesUI struct
method InitComponents (line 20) | func (e *EpisodesUI) InitComponents() error {
method MainUI (line 27) | func (e *EpisodesUI) MainUI() *ui.Grid {
method HandleEvent (line 31) | func (e *EpisodesUI) HandleEvent(event *ui.Event) (Command, error) {
method Play (line 45) | func (e *EpisodesUI) Play(index int) {
method initGridWidget (line 49) | func (e *EpisodesUI) initGridWidget() error {
method initListWidget (line 68) | func (e *EpisodesUI) initListWidget() {
method initDetailsWidget (line 81) | func (e *EpisodesUI) initDetailsWidget() {
method updateDetailsWidget (line 89) | func (e *EpisodesUI) updateDetailsWidget() {
FILE: ui/genres.go
type GenresUI (line 11) | type GenresUI struct
method InitComponents (line 17) | func (g *GenresUI) InitComponents() error {
method MainUI (line 25) | func (g *GenresUI) MainUI() *ui.Grid {
method HandleEvent (line 28) | func (g *GenresUI) HandleEvent(event *ui.Event) (Command, error) {
method newGenresListWidget (line 48) | func (g *GenresUI) newGenresListWidget() error {
method newGridWidget (line 64) | func (g *GenresUI) newGridWidget() error {
FILE: ui/page.go
type Page (line 20) | type Page interface
function InitUI (line 25) | func InitUI() error {
function Show (line 33) | func Show(p Page) {
function show (line 41) | func show(p Page) {
function RefreshUI (line 46) | func RefreshUI() {
function GoBack (line 51) | func GoBack() {
function HandleKeyEvent (line 59) | func HandleKeyEvent(e *ui.Event) (Command, error) {
function newHelpBarWidget (line 86) | func newHelpBarWidget() *widgets.Paragraph {
FILE: ui/sub_genres.go
type SubGenresUI (line 11) | type SubGenresUI struct
method InitComponents (line 17) | func (g *SubGenresUI) InitComponents() error {
method MainUI (line 25) | func (g *SubGenresUI) MainUI() *ui.Grid {
method HandleEvent (line 28) | func (g *SubGenresUI) HandleEvent(event *ui.Event) (Command, error) {
method newGenresListWidget (line 49) | func (g *SubGenresUI) newGenresListWidget() error {
method newGridWidget (line 65) | func (g *SubGenresUI) newGridWidget() error {
method refreshComponents (line 84) | func (g *SubGenresUI) refreshComponents() {
Condensed preview — 19 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (41K chars).
[
{
"path": ".gitignore",
"chars": 371,
"preview": "# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# binary\npodcast-cli*\nmain*\n!main.go\n\n# Test binary"
},
{
"path": ".vscode/launch.json",
"chars": 444,
"preview": "{\n // Use IntelliSense to learn about possible attributes.\n // Hover to view descriptions of existing attributes.\n"
},
{
"path": "README.md",
"chars": 1865,
"preview": "<p align=\"center\"><img width=\"200px\" src=\"/resources/img/logo.png\" alt=\"podcast-cli\"/></p>\n\n\n___\n\nTop-like interface for"
},
{
"path": "audio-player/player.go",
"chars": 3211,
"preview": "package audioplayer\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"os\"\n\t\"path\"\n\t\"strings\"\n\t\"time\"\n\n\t\"g"
},
{
"path": "config/main.go",
"chars": 98,
"preview": "package config\n\nimport (\n\t\"os\"\n\t\"path\"\n)\n\nvar CachePath = path.Join(os.TempDir(), \"podcast-cli/\")\n"
},
{
"path": "go.mod",
"chars": 250,
"preview": "module github.com/goulinkh/podcast-cli\n\ngo 1.14\n\nrequire (\n\tgithub.com/PuerkitoBio/goquery v1.5.1\n\tgithub.com/akamensky/"
},
{
"path": "go.sum",
"chars": 4939,
"preview": "github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE=\ngithub.com/PuerkitoBio/goquery v1."
},
{
"path": "itunes-api/episodes.go",
"chars": 395,
"preview": "package itunesapi\n\ntype Episode struct {\n\tId string `json:\"id\"`\n\tArtwork string `json"
},
{
"path": "itunes-api/genres.go",
"chars": 1342,
"preview": "package itunesapi\n\nimport (\n\t\"net/http\"\n\t\"regexp\"\n\n\t\"github.com/PuerkitoBio/goquery\"\n)\n\nconst applePodcastsMainPage = \"h"
},
{
"path": "itunes-api/podcasts.go",
"chars": 4429,
"preview": "package itunesapi\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"regexp\"\n\n\t\"github.com/tidwall/gjson\"\n)\n\ntype Pod"
},
{
"path": "main.go",
"chars": 1985,
"preview": "package main\n\nimport (\n\t\"log\"\n\t\"os\"\n\n\t\"github.com/akamensky/argparse\"\n\tui \"github.com/gizak/termui/v3\"\n\n\titunesapi \"gith"
},
{
"path": "rss/parser.go",
"chars": 1350,
"preview": "package rss\n\nimport (\n\t\"encoding/xml\"\n\t\"io/ioutil\"\n\t\"net/http\"\n\t\"strconv\"\n\n\titunesapi \"github.com/goulinkh/podcast-cli/i"
},
{
"path": "ui/Podcasts.go",
"chars": 2794,
"preview": "package ui\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\tui \"github.com/gizak/termui/v3\"\n\t\"github.com/gizak/termui/v3/widgets\""
},
{
"path": "ui/audio_player.go",
"chars": 3751,
"preview": "package ui\n\nimport (\n\t\"fmt\"\n\t\"time\"\n\n\tui \"github.com/gizak/termui/v3\"\n\t\"github.com/gizak/termui/v3/widgets\"\n\taudioplayer"
},
{
"path": "ui/commands.go",
"chars": 82,
"preview": "package ui\n\ntype Command int\n\nconst (\n\tNothing Command = 0\n\tExit Command = 1\n)\n"
},
{
"path": "ui/episodes.go",
"chars": 2855,
"preview": "package ui\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"strings\"\n\n\tui \"github.com/gizak/termui/v3\"\n\t\"github.com/gizak/termui/v3/widgets\""
},
{
"path": "ui/genres.go",
"chars": 2178,
"preview": "package ui\n\nimport (\n\t\"errors\"\n\n\tui \"github.com/gizak/termui/v3\"\n\t\"github.com/gizak/termui/v3/widgets\"\n\titunesapi \"githu"
},
{
"path": "ui/page.go",
"chars": 2465,
"preview": "package ui\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\tui \"github.com/gizak/termui/v3\"\n\t\"github.com/gizak/termui/v3/widgets\"\n)\n\nvar (\n\t"
},
{
"path": "ui/sub_genres.go",
"chars": 2165,
"preview": "package ui\n\nimport (\n\t\"errors\"\n\n\tui \"github.com/gizak/termui/v3\"\n\t\"github.com/gizak/termui/v3/widgets\"\n\titunesapi \"githu"
}
]
About this extraction
This page contains the full source code of the goulinkh/podcast-cli GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 19 files (36.1 KB), approximately 12.1k tokens, and a symbol index with 74 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.