feat: Support the whole audio addict network

This commit is contained in:
Adriano Caloiaro 2024-05-05 11:13:57 -07:00
parent f2886123b4
commit 36c412afe1
No known key found for this signature in database
GPG key ID: C2BC56DE73CE3F75
10 changed files with 843 additions and 215 deletions

View file

@ -25,8 +25,8 @@ jobs:
git config --global user.email "actions@github.com"
git config --global user.name "Github Actions"
go install git.sr.ht/~jcmuller/semver-bumper@latest
OLD_TAG=$(git tag --list 'v*' | semver-bumper -s)
NEW_TAG=$(git tag --list 'v*' | semver-bumper --increment minor)
OLD_TAG=$(git tag --list '[0-9]*.[0-9]*.[0-9]*' | semver-bumper -s)
NEW_TAG=$(git tag --list '[0-9]*.[0-9]*.[0-9]*' | semver-bumper --increment minor)
sed -i "s/$OLD_TAG/$NEW_TAG/g" default.nix
git add default.nix
git commit -m "Bump version in default.nix" --allow-empty

View file

@ -1,27 +1,18 @@
# di-tui
A simple terminal UI player for [di.fm Premium](http://di.fm)
![App Screenshot](https://github.com/acaloiaro/di-tui/assets/3331648/5b85343f-d098-48d8-ae98-4bd1e99e0a8b)
## Dependencies
This app began as di.fm player, but now supports the whole Audio Addict network
### PulseAudio
- Classical Radio
- DI.fm
- Radio Tunes
- Rock Radio
- Jazz Radio
- Zen Radio
Both linux and MacOS depend on pulseaudio to be running.
#### MacOS
By default, pulseaudio on MacOS runs as "root", which is not ideal. PulseAudio is best run by non-root users. By symbolically linking the pulseaudio plist file into your user's `~/Library/LaunchAgents/`, it runs as your user.
```
brew install pulseaudio
ln -s $(brew info pulseaudio | grep "/usr/local/Cellar" | awk '{print $1}')//homebrew.mxcl.pulseaudio.plist ~/Library/LaunchAgents
brew services start pulseaudio
```
#### Debian / Ubuntu
`apt install pulseaudio`
## Install
@ -43,17 +34,29 @@ source ~/.bashrc
nix run github:acaloiaro/di-tui
```
## Authenticate
## Usage
### Authenticate
There are two authentication options
**NOTE** di.fm has started using Cloudflare on di.fm, which prevents `di-tui` from logging in with username and password. This also prevents adding/removing favorites within di-tui, since that functionality requires a credential that's separate from di.fm's "listen key", and only available by logging in with username/password. Use your Listen Key if you're a new `di-tui` user. If you've previously logged in with username and password, you'll remain logged in as long as `~/.config/di-tui/config.yml` remains in place.
- Enter your username and password directly into `di-tui`
- Enter your username and password directly into `di-tui` with the `--username` and `--password` switches
- If you're justifiably uncomfortable with entering your username/password into this application, copy your "Listen Key" from (https://www.di.fm/settings) and create the following file:
### ~/.config/di-tui/config.yml
### Choose a network
DI.fm is the default network, but other audio addict networks can be chosen with the `--network` switch.
| switch value | network |
| --- | ----------- |
| classicalradio | Classical Radio [https://classicalradio.com](https://classicalradio.com) |
| di | DI.fm [https://di.fm](https://di.fm) |
| radiotunes | Radio Tunes [https://radiotunes.com](https://radiotunes.com) |
| rockradio | Rock Radio [https://rockradio.com](https://rockradio.com)|
| jazzradio | Jazz Radio [https://jazzradio.com](https://jazzradio.com)|
| zenradio | Zen Radio [https://zenradio.com](https://zenradio.com)|
#### ~/.config/di-tui/config.yml
```yml
token: <YOUR LISTEN KEY>
album_art: <BOOLEAN>
@ -61,14 +64,34 @@ album_art: <BOOLEAN>
| key | description |
| --- | ----------- |
| token | Your di.fm authentication "Listen Key" found at https://www.di.fm/settings |
| album_art | Turn album art on or off |
| token | **string** Your di.fm authentication "Listen Key" found at https://www.di.fm/settings |
| album_art | **boolean** Enable/disable audio art |
## Dependencies
### PulseAudio
Both linux and MacOS depend on pulseaudio to be running.
#### MacOS
By default, pulseaudio on MacOS runs as "root", which is not ideal. PulseAudio is best run by non-root users. By symbolically linking the pulseaudio plist file into your user's `~/Library/LaunchAgents/`, it runs as your user.
```
brew install pulseaudio
ln -s $(brew info pulseaudio | grep "/usr/local/Cellar" | awk '{print $1}')//homebrew.mxcl.pulseaudio.plist ~/Library/LaunchAgents
brew services start pulseaudio
```
#### Debian / Ubuntu
`apt install pulseaudio`
## MPRIS/D-bus support
MPRIS is a D-Bus specification allowing media players to be controlled in a standardized way, e.g. with `playerctl`.
`di-tui` supports a very limited set of MPRIS commands. The limited set is to to the fact that `di-tui` is a streaming audio player, and it doesn't make sense to support `next`, `previous`, `seek`, etc., because audio streams have no next or previous track; or the ability to seek forward.
`di-tui` supports a very limited set of MPRIS commands. The limited set is due to the fact that `di-tui` is a streaming audio player, and it doesn't make sense to support `next`, `previous`, `seek`, etc., because audio streams have no next or previous track; or the ability to seek forward.
### Supported MPRIS commands
@ -84,7 +107,6 @@ MPRIS is a D-Bus specification allowing media players to be controlled in a stan
`playerName` The name of the player: `di-tui`
## Configuration
### Themes

View file

@ -4,6 +4,13 @@ import (
"time"
)
// Network is an audio addict network
type Network struct {
ListenURL string
Name string
ShortName string // the name used in API URL paths
}
// ChannelItem contains di.fm channel metadata
type ChannelItem struct {
ID int64 `json:"id"`

View file

@ -5,6 +5,7 @@ import (
"os"
"runtime"
"github.com/acaloiaro/di-tui/components"
"github.com/spf13/viper"
)
@ -24,6 +25,7 @@ func init() {
viper.AddConfigPath(".")
viper.SetDefault("album_art", true)
viper.SetDefault("network.shortname", "di")
err := viper.ReadInConfig()
if err != nil {
@ -89,6 +91,20 @@ func SaveListenToken(token string) {
saveConfig()
}
// SaveAPIKey saves the user's API key
func SaveAPIKey(key string) {
viper.Set("api_key", key)
saveConfig()
}
// SaveNetwork saves the user's default network
func SaveNetwork(network *components.Network) {
viper.Set("network", network)
saveConfig()
}
// HasTheme returns true if the di-tui config has a Theme set
func HasTheme() bool {
if C.Theme["primary_color"] != "" {

View file

@ -21,23 +21,25 @@ func CreateAppContext(view *views.ViewContext) *AppContext {
// AppContext is a shared context to be shared across the application
// AudioStream - The raw audio stream from which audio is streamed by the player
// CurrentChannel - The ChannelItem representing the currently playing di.fm channel
// DfmToken - The token used to authenticate to di.fm
// CurrentChannel - The ChannelItem representing the currently playing on the network
// DfmToken - The token used to authenticate to the network
// IsPlaying - Is there audio playing?
// Netowrk - the network to connect to
// View - The view context
// Status - Gets and sets current application status messages
type AppContext struct {
AudioStream io.ReadCloser
Player *pulse.PlaybackStream
ChannelList []components.ChannelItem
CurrentChannel *components.ChannelItem
DifmToken string
FavoriteList []components.FavoriteItem
Network *components.Network
HighlightedChannel *components.ChannelItem
IsPlaying bool
Player *pulse.PlaybackStream
ShowStatus bool // The status pane will be visible when true
StatusChannel chan components.StatusMessage
View *views.ViewContext
ChannelList []components.ChannelItem
FavoriteList []components.FavoriteItem
HighlightedChannel *components.ChannelItem
}
// SetStatusMessage sets the application's status message for five seconds.

View file

@ -8,83 +8,78 @@ import (
"io"
"log"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/acaloiaro/di-tui/context"
"github.com/acaloiaro/di-tui/player"
"github.com/acaloiaro/di-tui/components"
"github.com/acaloiaro/di-tui/config"
"github.com/acaloiaro/di-tui/context"
"github.com/acaloiaro/di-tui/player"
"github.com/bradfitz/iter"
ini "gopkg.in/ini.v1"
)
type ApplicationMetadata struct {
User struct {
ID int64 `json:"id"`
AudioToken string `json:"audio_token"`
SessionKey string `json:"session_key"`
} `json:"user"`
CsrfToken string
var Networks = map[string]*components.Network{
"classicalradio": {
Name: "Classical Radio",
ListenURL: "http://listen.classicalradio.com",
ShortName: "classicalradio",
},
"di": {
Name: "DI.fm",
ListenURL: "http://listen.di.fm",
ShortName: "di",
},
"radiotunes": {
Name: "RadioTunes",
ListenURL: "http://listen.radiotunes.com",
ShortName: "radiotunes",
},
"rockradio": {
Name: "ROCKRADIO.COM",
ListenURL: "http://listen.rockradio.com",
ShortName: "rockradio",
},
"jazzradio": {
Name: "JazzRadio",
ListenURL: "http://listen.jazzradio.com",
ShortName: "jazzradio",
},
"zenradio": {
Name: "Zen Radio",
ListenURL: "http://listen.zenradio.com",
ShortName: "zenradio",
},
}
func GetNetwork(name string) (network *components.Network, err error) {
var ok bool
if network, ok = Networks[name]; !ok {
return nil, fmt.Errorf("network does not exist: %s", network)
}
return
}
type authResponse struct {
ListenKey string `json:"listen_key"`
APIKey string `json:"api_key"`
}
// Authenticate authenticates to the di.fm API with a username and password
//
// Note: There is a more API-friendly way of authenticating to the audioaddict API. However, because only the "web
// player" allows interactions such as adding/removing favorite channels, we're obligated to authenticate in the way
// that the web player authenticates.
// login workflow
// 1. GET www.di.fm/login to get the CSRF token
// 2. POST www.di.fm/login (with CSRF token and other appropriate headers)
// 3. GET www.di.fm/ to retrieve two key pieces of information
// - an "audio_token"
// - a "session_key"
//
// Both of which are required to perform advanced player features such as adding/removing favorites.
//
// One added benefit of logging in this way is that di-tui may use the audio token to stream "on-demand" audio in the
// future, which would allow the player to buffer content. Currently, no buffering is performed because di-tui uses the
// streaming API.
//
// However, until a developer-friendly, go-native AAC decoder is available, this player will
// continue using the MP3 "streaming" API.
func Authenticate(ctx *context.AppContext, username, password string) (token string) {
jar, err := cookiejar.New(nil)
if err != nil {
log.Fatalf("Got error while creating cookie jar %s", err.Error())
}
client := &http.Client{Jar: jar}
// 1. GET www.di.fm/login to get the CSRF token
meta, _ := getApplicationMetadata(ctx, client)
authURL := "https://www.di.fm/login"
// Authenticate authenticates to the audio addict API
func Authenticate(ctx *context.AppContext, username, password string) (err error) {
authURL := fmt.Sprintf("https://api.audioaddict.com/v1/%s/members/authenticate", ctx.Network.ShortName)
log.Println(authURL)
client := &http.Client{}
data := url.Values{}
data.Set("member_session[username]", username)
data.Set("member_session[password]", password)
data.Set("username", username)
data.Set("password", password)
encodedData := data.Encode()
// 2. POST www.di.fm/login (with CSRF token and other appropriate headers)
req, _ := http.NewRequest("POST", authURL, strings.NewReader(encodedData))
req.Header.Add("Content-Length", strconv.Itoa(len(encodedData)))
req.Header.Add("Origin", "https://www.di.fm")
req.Header.Add("Referrer", "https://www.di.fm")
req.Header.Add("Sec-Fetch-Mode", "cors")
req.Header.Add("Sec-Fetch-Dest", "empty")
req.Header.Add("Sec-Fetch-Site", "same-origin")
req.Header.Add("X-Requested-With", "XMLHttpRequest")
req.Header.Add("X-CSRF-Token", meta.CsrfToken)
resp, err := client.Do(req)
if err != nil {
@ -94,69 +89,19 @@ func Authenticate(ctx *context.AppContext, username, password string) (token str
var res authResponse
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != 200 {
fmt.Println("Unable to authenticate to di.fm. Status code:", resp.StatusCode)
os.Exit(1)
if err != nil {
log.Fatal("unable to authenticate", err.Error())
}
json.Unmarshal(body, &res)
err = json.Unmarshal([]byte(body), &res)
if err != nil {
log.Fatalf("unable to reason API response: %v", err)
}
config.SaveListenToken(res.ListenKey)
// 3. GET www.di.fm/ to retrieve two key pieces of information
meta, err = getApplicationMetadata(ctx, client)
if err != nil {
fmt.Println("Unable to fetch audio token and session key")
os.Exit(1)
}
config.SaveAudioToken(meta.User.AudioToken)
config.SaveSessionKey(meta.User.SessionKey)
config.SaveUserID(meta.User.ID)
return
}
// GetApplicationMetadata fetches the application's metadata (the di.fm player application) from www.di.fm
func getApplicationMetadata(ctx *context.AppContext, client *http.Client) (appMeta ApplicationMetadata, err error) {
var req *http.Request
req, err = http.NewRequest("GET", "https://www.di.fm", nil)
if err != nil {
return
}
req.Header.Add("Sec-Fetch-Mode", "cors")
req.Header.Add("Sec-Fetch-Dest", "empty")
req.Header.Add("Sec-Fetch-Site", "same-origin")
var resp *http.Response
resp, err = client.Do(req)
if err != nil || resp.StatusCode != 200 {
return
}
defer resp.Body.Close()
var body []byte
body, err = io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != 200 {
return
}
bodyStr := string(body)
re := regexp.MustCompile(`.*di\.app\.start\((.*)\);.*`)
matches := re.FindStringSubmatch(bodyStr)
if len(matches) > 0 {
appMeta = ApplicationMetadata{}
err = json.Unmarshal([]byte(matches[1]), &appMeta)
if err != nil {
return
}
}
re = regexp.MustCompile(`.*meta name="csrf-token" content="(.*?)"/>`)
matches = re.FindStringSubmatch(bodyStr)
if len(matches) > 0 {
appMeta.CsrfToken = matches[1]
}
config.SaveAPIKey(res.APIKey)
config.SaveNetwork(ctx.Network)
return
}
@ -179,14 +124,14 @@ func GetStreamURL(data []byte, ctx *context.AppContext) (streamURL string, ok bo
// GetCurrentlyPlaying fetches the list of all currently playing tracks site-side
func GetCurrentlyPlaying(ctx *context.AppContext) (currentlyPlaying components.CurrentlyPlaying) {
client := &http.Client{}
req, _ := http.NewRequest("GET", "https://api.audioaddict.com/v1/di/currently_playing", nil)
url := fmt.Sprintf("https://api.audioaddict.com/v1/%s/currently_playing", ctx.Network.ShortName)
req, _ := http.NewRequest("GET", url, nil)
resp, err := client.Do(req)
if err != nil || resp.StatusCode != 200 {
ctx.SetStatusMessage("Unable to fetch currently playing track info")
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != 200 {
ctx.SetStatusMessage("Unable to fetch currently playing track info.")
@ -209,7 +154,8 @@ func GetCurrentlyPlaying(ctx *context.AppContext) (currentlyPlaying components.C
// ListChannels lists all premium MP3 channels
func ListChannels(ctx *context.AppContext) (channels []components.ChannelItem) {
client := &http.Client{}
req, _ := http.NewRequest("GET", "http://listen.di.fm/premium_high", nil)
url := fmt.Sprintf("%s/premium_high", ctx.Network.ListenURL)
req, _ := http.NewRequest("GET", url, nil)
resp, err := client.Do(req)
if err != nil || resp.StatusCode != 200 {
ctx.SetStatusMessage("Unable to fetch the list of channels")
@ -235,7 +181,7 @@ func ListChannels(ctx *context.AppContext) (channels []components.ChannelItem) {
// ListFavorites lists a user's favorite channels
func ListFavorites(ctx *context.AppContext) (favorites []components.FavoriteItem) {
client := &http.Client{}
url := fmt.Sprintf("%s?%s", "http://listen.di.fm/premium_high/favorites.pls", ctx.DifmToken)
url := fmt.Sprintf("%s%s?%s", ctx.Network.ListenURL, "/premium_high/favorites.pls", ctx.DifmToken)
req, _ := http.NewRequest("GET", url, nil)
resp, err := client.Do(req)
if err != nil || resp.StatusCode != 200 {
@ -265,41 +211,6 @@ func ListFavorites(ctx *context.AppContext) (favorites []components.FavoriteItem
return
}
// ToggleFavorite adds/removes the currentlly selected channel to/from the user's favorites
func ToggleFavorite(ctx *context.AppContext) {
if config.GetSessionKey() == "" {
ctx.SetStatusMessage("Unfortunately you must log in to di-tui with a username and password to change favorites.")
return
}
client := &http.Client{}
url := fmt.Sprintf("https://api.audioaddict.com/v1/di/members/%d/favorites/channel/%d", config.GetUserID(), ctx.HighlightedChannel.ID)
requestMethod := "POST"
focusedView := ctx.View.App.GetFocus()
// if the user is currently viewing favorites, then the request is to remove the channel from the favorite list
if focusedView == ctx.View.FavoriteList {
requestMethod = "DELETE"
}
url = fmt.Sprintf("%s?audio_token=%s", url, config.GetAudioToken())
var jsonStr = []byte(fmt.Sprintf(`{"id": %d}`, ctx.HighlightedChannel.ID))
req, _ := http.NewRequest(requestMethod, url, bytes.NewBuffer(jsonStr))
req.Header.Add("Sec-Fetch-Mode", "cors")
req.Header.Add("Sec-Fetch-Dest", "empty")
req.Header.Add("Sec-Fetch-Site", "same-origin")
req.Header.Add("X-Session-Key", config.GetSessionKey())
resp, err := client.Do(req)
if err != nil || (resp.StatusCode != 204 && resp.StatusCode != 200) {
ctx.SetStatusMessage("There was a problem updating channel favorites")
return
}
defer resp.Body.Close()
}
// Stream streams the provided URL using the given di.fm premium token
func Stream(url string, ctx *context.AppContext) {
client := http.DefaultClient
@ -346,10 +257,11 @@ func Stream(url string, ctx *context.AppContext) {
// FavoriteItemChannel identifies the ChannelItem that corresponds with a FavoriteItem
func FavoriteItemChannel(ctx *context.AppContext, favorite components.FavoriteItem) (channel *components.ChannelItem) {
// favorites are prefixed with "<NETWORK-NAME> - <CHANNEL NAME>", e.g. "DI.fm - Liquid Trap"
// shave it off before comparing
prefix := fmt.Sprintf("%s - ", ctx.Network.Name)
for _, chn := range ctx.ChannelList {
// favorites are prefixed with "DI.fm - <CHANNEL NAME>", shave it off before comparing
// TODO: this feels a bit hacky -- consider doing something else.
if chn.Name == favorite.Name[8:len(favorite.Name)] {
if chn.Name == favorite.Name[len(prefix):len(favorite.Name)] {
channel = &chn
return
}

View file

@ -1,5 +1,30 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": "devenv_3",
"flake-compat": "flake-compat_3",
"nixpkgs": [
"semver-bumper",
"devenv",
"nixpkgs"
],
"pre-commit-hooks": "pre-commit-hooks_2"
},
"locked": {
"lastModified": 1710475558,
"narHash": "sha256-egKrPCKjy/cE+NqCj4hg2fNX/NwLCf0bRDInraYXDgs=",
"owner": "cachix",
"repo": "cachix",
"rev": "661bbb7f8b55722a0406456b15267b5426a3bda6",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "cachix",
"type": "github"
}
},
"devenv": {
"inputs": {
"flake-compat": "flake-compat",
@ -21,6 +46,61 @@
"type": "github"
}
},
"devenv_2": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat_5",
"nix": "nix_3",
"nixpkgs": "nixpkgs_4",
"pre-commit-hooks": "pre-commit-hooks_3"
},
"locked": {
"lastModified": 1711371145,
"narHash": "sha256-abuKk8NiXO+tMTOtt+4QbSaFsSmN+Vo4UGLDO4+Ncjg=",
"owner": "cachix",
"repo": "devenv",
"rev": "e0440c5c88ae2a56813402f32377140840203c52",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"devenv_3": {
"inputs": {
"flake-compat": [
"semver-bumper",
"devenv",
"cachix",
"flake-compat"
],
"nix": "nix_2",
"nixpkgs": "nixpkgs_3",
"poetry2nix": "poetry2nix",
"pre-commit-hooks": [
"semver-bumper",
"devenv",
"cachix",
"pre-commit-hooks"
]
},
"locked": {
"lastModified": 1708704632,
"narHash": "sha256-w+dOIW60FKMaHI1q5714CSibk99JfYxm0CzTinYWr+Q=",
"owner": "cachix",
"repo": "devenv",
"rev": "2ee4450b0f4b95a1b90f2eb5ffea98b90e48c196",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "python-rewrite",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
@ -37,6 +117,86 @@
"type": "github"
}
},
"flake-compat_2": {
"flake": false,
"locked": {
"lastModified": 1673956053,
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-compat_3": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-compat_4": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-compat_5": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-compat_6": {
"flake": false,
"locked": {
"lastModified": 1673956053,
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@ -73,6 +233,78 @@
"type": "github"
}
},
"flake-utils_3": {
"inputs": {
"systems": "systems_3"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_4": {
"inputs": {
"systems": "systems_4"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_5": {
"inputs": {
"systems": "systems_5"
},
"locked": {
"lastModified": 1701680307,
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_6": {
"inputs": {
"systems": "systems_6"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
@ -95,6 +327,53 @@
"type": "github"
}
},
"gitignore_2": {
"inputs": {
"nixpkgs": [
"semver-bumper",
"devenv",
"cachix",
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1703887061,
"narHash": "sha256-gGPa9qWNc6eCXT/+Z5/zMkyYOuRZqeFZBDbopNZQkuY=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "43e1aa1308018f37118e34d3a9cb4f5e75dc11d5",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gitignore_3": {
"inputs": {
"nixpkgs": [
"semver-bumper",
"devenv",
"pre-commit-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1703887061,
"narHash": "sha256-gGPa9qWNc6eCXT/+Z5/zMkyYOuRZqeFZBDbopNZQkuY=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "43e1aa1308018f37118e34d3a9cb4f5e75dc11d5",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"gomod2nix": {
"inputs": {
"flake-utils": "flake-utils_2",
@ -116,6 +395,28 @@
"type": "github"
}
},
"gomod2nix_2": {
"inputs": {
"flake-utils": "flake-utils_6",
"nixpkgs": [
"semver-bumper",
"nixpkgs"
]
},
"locked": {
"lastModified": 1710154385,
"narHash": "sha256-4c3zQ2YY4BZOufaBJB4v9VBBeN2dH7iVdoJw8SDNCfI=",
"owner": "nix-community",
"repo": "gomod2nix",
"rev": "872b63ddd28f318489c929d25f1f0a3c6039c971",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "gomod2nix",
"type": "github"
}
},
"lowdown-src": {
"flake": false,
"locked": {
@ -156,6 +457,83 @@
"type": "github"
}
},
"nix-github-actions": {
"inputs": {
"nixpkgs": [
"semver-bumper",
"devenv",
"cachix",
"devenv",
"poetry2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1688870561,
"narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "165b1650b753316aa7f1787f3005a8d2da0f5301",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-github-actions",
"type": "github"
}
},
"nix_2": {
"inputs": {
"flake-compat": "flake-compat_2",
"nixpkgs": [
"semver-bumper",
"devenv",
"cachix",
"devenv",
"nixpkgs"
],
"nixpkgs-regression": "nixpkgs-regression_2"
},
"locked": {
"lastModified": 1708577783,
"narHash": "sha256-92xq7eXlxIT5zFNccLpjiP7sdQqQI30Gyui2p/PfKZM=",
"owner": "domenkozar",
"repo": "nix",
"rev": "ecd0af0c1f56de32cbad14daa1d82a132bf298f8",
"type": "github"
},
"original": {
"owner": "domenkozar",
"ref": "devenv-2.21",
"repo": "nix",
"type": "github"
}
},
"nix_3": {
"inputs": {
"flake-compat": "flake-compat_6",
"nixpkgs": [
"semver-bumper",
"devenv",
"nixpkgs"
],
"nixpkgs-regression": "nixpkgs-regression_3"
},
"locked": {
"lastModified": 1710500156,
"narHash": "sha256-zvCqeUO2GLOm7jnU23G4EzTZR7eylcJN+HJ5svjmubI=",
"owner": "domenkozar",
"repo": "nix",
"rev": "c5bbf14ecbd692eeabf4184cc8d50f79c2446549",
"type": "github"
},
"original": {
"owner": "domenkozar",
"ref": "devenv-2.21",
"repo": "nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1678875422,
@ -188,6 +566,38 @@
"type": "github"
}
},
"nixpkgs-regression_2": {
"locked": {
"lastModified": 1643052045,
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
}
},
"nixpkgs-regression_3": {
"locked": {
"lastModified": 1643052045,
"narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1685801374,
@ -204,6 +614,38 @@
"type": "github"
}
},
"nixpkgs-stable_2": {
"locked": {
"lastModified": 1704874635,
"narHash": "sha256-YWuCrtsty5vVZvu+7BchAxmcYzTMfolSPP5io8+WYCg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3dc440faeee9e889fe2d1b4d25ad0f430d449356",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable_3": {
"locked": {
"lastModified": 1704874635,
"narHash": "sha256-YWuCrtsty5vVZvu+7BchAxmcYzTMfolSPP5io8+WYCg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3dc440faeee9e889fe2d1b4d25ad0f430d449356",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-23.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1708751719,
@ -220,6 +662,64 @@
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1692808169,
"narHash": "sha256-x9Opq06rIiwdwGeK2Ykj69dNc2IvUH1fY55Wm7atwrE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9201b5ff357e781bf014d0330d18555695df7ba8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_4": {
"locked": {
"lastModified": 1710236354,
"narHash": "sha256-vWrciFdq49vve43g4pbi7NjmL4cwG1ifXnQx+dU3T5E=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "829e73affeadfb4198a7105cbe3a03153d13edc9",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"poetry2nix": {
"inputs": {
"flake-utils": "flake-utils_3",
"nix-github-actions": "nix-github-actions",
"nixpkgs": [
"semver-bumper",
"devenv",
"cachix",
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1692876271,
"narHash": "sha256-IXfZEkI0Mal5y1jr6IRWMqK8GW2/f28xJenZIPQqkY0=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "d5006be9c2c2417dafb2e2e5034d83fabd207ee3",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "poetry2nix",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-compat": [
@ -248,12 +748,93 @@
"type": "github"
}
},
"pre-commit-hooks_2": {
"inputs": {
"flake-compat": "flake-compat_4",
"flake-utils": "flake-utils_4",
"gitignore": "gitignore_2",
"nixpkgs": [
"semver-bumper",
"devenv",
"cachix",
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable_2"
},
"locked": {
"lastModified": 1708018599,
"narHash": "sha256-M+Ng6+SePmA8g06CmUZWi1AjG2tFBX9WCXElBHEKnyM=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "5df5a70ad7575f6601d91f0efec95dd9bc619431",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"pre-commit-hooks_3": {
"inputs": {
"flake-compat": [
"semver-bumper",
"devenv",
"flake-compat"
],
"flake-utils": "flake-utils_5",
"gitignore": "gitignore_3",
"nixpkgs": [
"semver-bumper",
"devenv",
"nixpkgs"
],
"nixpkgs-stable": "nixpkgs-stable_3"
},
"locked": {
"lastModified": 1708018599,
"narHash": "sha256-M+Ng6+SePmA8g06CmUZWi1AjG2tFBX9WCXElBHEKnyM=",
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"rev": "5df5a70ad7575f6601d91f0efec95dd9bc619431",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "pre-commit-hooks.nix",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"gomod2nix": "gomod2nix",
"nixpkgs": "nixpkgs_2",
"systems": "systems_3"
"semver-bumper": "semver-bumper",
"systems": "systems_8"
}
},
"semver-bumper": {
"inputs": {
"devenv": "devenv_2",
"gomod2nix": "gomod2nix_2",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems_7"
},
"locked": {
"lastModified": 1711378352,
"narHash": "sha256-f+fJ+FIs7jABDGoj6ZasAqstAMGeP4RVWvy/rwZIJcc=",
"owner": "~jcmuller",
"repo": "semver-bumper",
"rev": "eb01c170dcbaf759b9f5bdc9d53ca2c489f5a465",
"type": "sourcehut"
},
"original": {
"owner": "~jcmuller",
"repo": "semver-bumper",
"type": "sourcehut"
}
},
"systems": {
@ -300,6 +881,81 @@
"repo": "default",
"type": "github"
}
},
"systems_4": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_5": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_6": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_7": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_8": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View file

@ -8,6 +8,11 @@
url = "github:nix-community/gomod2nix";
inputs.nixpkgs.follows = "nixpkgs";
};
semver-bumper = {
url = "sourcehut:~jcmuller/semver-bumper";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
@ -16,6 +21,7 @@
devenv,
systems,
gomod2nix,
semver-bumper,
...
} @ inputs: let
forEachSystem = nixpkgs.lib.genAttrs (import systems);
@ -43,6 +49,7 @@
gomod2nix.legacyPackages.${system}.gomod2nix
golangci-lint
pre-commit
semver-bumper.packages.${system}.default
];
pre-commit.hooks.gomod2nix = {

51
main.go
View file

@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"strings"
"time"
"github.com/acaloiaro/di-tui/app"
@ -20,28 +21,35 @@ import (
var ctx *context.AppContext
func main() {
pflag.String("username", "", "your di.fm username")
pflag.String("password", "", "your di.fm password")
ctx = context.CreateAppContext(views.CreateViewContext())
var err error
username := pflag.String("username", "", "your di.fm username")
password := pflag.String("password", "", "your di.fm password")
network := pflag.String("network", viper.GetString("network.shortname"), "the audioaddict network to connect to")
pflag.Parse()
viper.BindPFlags(pflag.CommandLine)
username := viper.GetString("username")
password := viper.GetString("password")
var token string
if len(username) > 0 && len(password) > 0 {
difm.Authenticate(ctx, username, password)
ctx.Network, err = difm.GetNetwork(*network)
if err != nil {
var networks []string
for network := range difm.Networks {
networks = append(networks, network)
}
fmt.Printf("Invalid network: %s \nPlease choose from the following: %s\n", *network, strings.Join(networks, ", "))
return
}
token = config.GetToken()
if *username != "" && *password != "" {
difm.Authenticate(ctx, *username, *password)
}
token := config.GetToken()
if token == "" {
fmt.Println("Authenticate by running: di-tui --username USER --password PASSWORD\n\n" +
"Or, visit https://github.com/acaloiaro/di-tui#authenticate for other options.")
os.Exit(1)
}
ctx = context.CreateAppContext(views.CreateViewContext())
ctx.DifmToken = token
run()
@ -96,8 +104,12 @@ func updateScreenLayout() {
// configureEventHandling handles key press events, and regular UI updates such as the currently playing track
func configureEventHandling() {
ctx.View.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
focus := ctx.View.App.GetFocus().(*tview.List)
favoritesEmpty := len(ctx.FavoriteList) == 0
if favoritesEmpty {
ctx.View.App.SetFocus(ctx.View.ChannelList)
}
focus := ctx.View.App.GetFocus().(*tview.List)
switch event.Key() {
case tcell.KeyEnter:
current := focus.GetCurrentItem()
@ -118,16 +130,6 @@ func configureEventHandling() {
if focus != ctx.View.FavoriteList {
ctx.View.App.SetFocus(ctx.View.FavoriteList)
}
case 'F':
current := focus.GetCurrentItem()
if focus == ctx.View.ChannelList {
ctx.HighlightedChannel = &ctx.ChannelList[current]
} else {
highlightedFavorite := ctx.FavoriteList[current]
ctx.HighlightedChannel = difm.FavoriteItemChannel(ctx, highlightedFavorite)
}
difm.ToggleFavorite(ctx)
FetchFavoritesAndChannels()
case 'q':
ctx.View.App.Stop()
case 'j': // scroll down
@ -200,6 +202,11 @@ func FetchFavoritesAndChannels() {
ctx.ChannelList = channels
ctx.FavoriteList = favorites
if len(favorites) == 0 {
ctx.HighlightedChannel = &ctx.ChannelList[0]
return
}
// default the highlighted channel to the first favorite; even before users select a channel manually. This way,
// when di-tui starts and the user presses the "Play" media key, di-tui will start playing the first favorite
// instead of requiring them to choose the channel to be played

View file

@ -242,7 +242,6 @@ func GetKeybindings() (bindings []UIKeybinding) {
bindings = []UIKeybinding{
{Shortcut: "c", Description: "Channels", Func: func() {}},
{Shortcut: "f", Description: "Favorites", Func: func() {}},
{Shortcut: "F", Description: "Toggle Favorite", Func: func() {}},
{Shortcut: "j", Description: "Scroll Down", Func: func() {}},
{Shortcut: "k", Description: "Scroll Up", Func: func() {}},
{Shortcut: "q", Description: "Quit", Func: func() {}},