From 36c412afe1fa6b6442a5b4b662e4fd8b848076e9 Mon Sep 17 00:00:00 2001 From: Adriano Caloiaro Date: Sun, 5 May 2024 11:13:57 -0700 Subject: [PATCH] feat: Support the whole audio addict network --- .github/workflows/goreleaser.yml | 4 +- README.md | 76 ++-- components/components.go | 7 + config/config.go | 16 + context/context.go | 14 +- difm/difm.go | 224 ++++------- flake.lock | 658 ++++++++++++++++++++++++++++++- flake.nix | 7 + main.go | 51 +-- views/views.go | 1 - 10 files changed, 843 insertions(+), 215 deletions(-) diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 4d2fd39..d7ac669 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -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 diff --git a/README.md b/README.md index 653b5ba..aabe67c 100644 --- a/README.md +++ b/README.md @@ -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: album_art: @@ -61,14 +64,34 @@ album_art: | 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 diff --git a/components/components.go b/components/components.go index 319ea41..f8dbfee 100644 --- a/components/components.go +++ b/components/components.go @@ -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"` diff --git a/config/config.go b/config/config.go index 6b94af5..ab24ce2 100644 --- a/config/config.go +++ b/config/config.go @@ -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"] != "" { diff --git a/context/context.go b/context/context.go index 2c2ef48..ece23bd 100644 --- a/context/context.go +++ b/context/context.go @@ -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. diff --git a/difm/difm.go b/difm/difm.go index 397292b..e9b819c 100644 --- a/difm/difm.go +++ b/difm/difm.go @@ -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 " - ", 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 - ", 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 } diff --git a/flake.lock b/flake.lock index 3d4043b..182837e 100644 --- a/flake.lock +++ b/flake.lock @@ -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", diff --git a/flake.nix b/flake.nix index 0f65c1c..407414a 100644 --- a/flake.nix +++ b/flake.nix @@ -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 = { diff --git a/main.go b/main.go index 8f4b51f..ac372a4 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/views/views.go b/views/views.go index a32c4dc..968cdd7 100644 --- a/views/views.go +++ b/views/views.go @@ -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() {}},