1package prog 2 3import ( 4 "bufio" 5 "fmt" 6 "io" 7 "net/http" 8 "os" 9 "regexp" 10 "strings" 11 "time" 12 13 "github.com/ambientsound/visp/api" 14 "github.com/ambientsound/visp/clipboard" 15 "github.com/ambientsound/visp/commands" 16 "github.com/ambientsound/visp/db" 17 "github.com/ambientsound/visp/input" 18 "github.com/ambientsound/visp/input/keys" 19 "github.com/ambientsound/visp/list" 20 "github.com/ambientsound/visp/log" 21 "github.com/ambientsound/visp/multibar" 22 "github.com/ambientsound/visp/options" 23 "github.com/ambientsound/visp/player" 24 "github.com/ambientsound/visp/spotify/aggregator" 25 "github.com/ambientsound/visp/spotify/library" 26 spotify_proxyclient "github.com/ambientsound/visp/spotify/proxyclient" 27 spotify_tracklist "github.com/ambientsound/visp/spotify/tracklist" 28 "github.com/ambientsound/visp/style" 29 "github.com/ambientsound/visp/tabcomplete" 30 "github.com/ambientsound/visp/tokencache" 31 "github.com/ambientsound/visp/widgets" 32 "github.com/gdamore/tcell/v2" 33 "github.com/google/uuid" 34 "github.com/zmb3/spotify" 35) 36 37const ( 38 changePlayerStateDelay = time.Millisecond * 100 39 refreshInvalidTokenDeploy = time.Millisecond * 1 40 refreshTokenRetryInterval = time.Second * 30 41 refreshTokenTimeout = time.Second * 5 42 tickerInterval = time.Second * 1 43) 44 45type Visp struct { 46 Termui *widgets.Application 47 Tokencache tokencache.Tokencache 48 49 client *spotify.Client 50 clipboards *clipboard.List 51 commands chan string 52 db *db.List 53 history *spotify_tracklist.List 54 interpreter *input.Interpreter 55 library *spotify_library.List 56 list list.List 57 multibar *multibar.Multibar 58 player *player.State 59 quit chan interface{} 60 sequencer *keys.Sequencer 61 stylesheet style.Stylesheet 62 ticker *time.Ticker 63 tokenRefresh <-chan time.Time 64} 65 66var _ api.API = &Visp{} 67 68func (v *Visp) Init() { 69 tcf := func(in string) multibar.TabCompleter { 70 return tabcomplete.New(in, v) 71 } 72 v.clipboards = clipboard.New() 73 v.commands = make(chan string, 1024) 74 v.db = db.New() 75 v.interpreter = input.NewCLI(v) 76 v.library = spotify_library.New() 77 v.multibar = multibar.New(tcf) 78 v.player = player.NewState(spotify.PlayerState{}) 79 v.quit = make(chan interface{}, 1) 80 v.sequencer = keys.NewSequencer() 81 v.stylesheet = make(style.Stylesheet) 82 v.ticker = time.NewTicker(tickerInterval) 83 v.tokenRefresh = make(chan time.Time) 84 85 v.SetList(log.List(log.InfoLevel)) 86} 87 88func (v *Visp) Main() error { 89 for { 90 select { 91 case <-v.quit: 92 log.Infof("Exiting.") 93 return nil 94 95 case <-v.ticker.C: 96 err := v.updatePlayer() 97 if err != nil { 98 log.Errorf("Update player: %s", err) 99 if isSpotifyAccessTokenExpired(err) { 100 v.tokenRefresh = time.After(refreshInvalidTokenDeploy) 101 } 102 } 103 v.ticker.Reset(tickerInterval) 104 105 case <-v.tokenRefresh: 106 log.Infof("Spotify access token is too old, refreshing...") 107 err := v.refreshToken() 108 if err != nil { 109 log.Errorf("Refresh Spotify access token: %s", err) 110 } 111 112 // Send commands from the multibar into the main command queue. 113 case command := <-v.multibar.Commands(): 114 v.commands <- command 115 116 // Search input box. 117 case query := <-v.multibar.Searches(): 118 if len(query) == 0 { 119 break 120 } 121 client, err := v.Spotify() 122 if err != nil { 123 log.Errorf(err.Error()) 124 break 125 } 126 lst, err := spotify_aggregator.Search(*client, query, options.GetInt(options.Limit)) 127 if err != nil { 128 log.Errorf("spotify search: %s", err) 129 break 130 } 131 columns := options.GetString(options.ColumnsTracklists) 132 lst.SetID(uuid.New().String()) 133 lst.SetName(fmt.Sprintf("Search for '%s'", query)) 134 lst.SetVisibleColumns(strings.Split(columns, ",")) 135 v.SetList(lst) 136 137 // Process the command queue. 138 case command := <-v.commands: 139 err := v.Exec(command) 140 if err != nil { 141 log.Errorf(err.Error()) 142 v.multibar.Error(err) 143 } 144 145 // Try handling the input event in the multibar. 146 // If multibar is disabled (input mode = normal), try handling the event in the UI layer. 147 // If unhandled still, run it through the keyboard binding maps to try to get a command. 148 case ev := <-v.Termui.Events(): 149 if v.multibar.Input(ev) { 150 break 151 } 152 if v.Termui.HandleEvent(ev) { 153 break 154 } 155 cmd := v.keyEventCommand(ev) 156 if len(cmd) == 0 { 157 break 158 } 159 v.commands <- cmd 160 } 161 162 // Draw UI after processing any event. 163 v.Termui.Draw() 164 } 165} 166 167// Record the current "liked" status of the current track. 168func (v *Visp) updateLiked() error { 169 if v.player.Item == nil || len(v.player.Item.ID) == 0 { 170 return nil 171 } 172 173 log.Debugf("Fetching liked status") 174 175 client, err := v.Spotify() 176 if err != nil { 177 return err 178 } 179 180 liked, err := client.UserHasTracks(v.player.Item.ID) 181 if err != nil { 182 return err 183 } 184 185 if len(liked) != 1 { 186 return nil 187 } 188 189 v.player.SetLiked(liked[0]) 190 log.Debugf("Likes current track: %v", v.player.Liked()) 191 192 return nil 193} 194 195func (v *Visp) updatePlayer() error { 196 var err error 197 198 now := time.Now() 199 pollInterval := time.Second * time.Duration(options.GetInt(options.PollInterval)) 200 201 // no time for polling yet; just increase the ticker. 202 if v.player.CreateTime.Add(pollInterval).After(now) { 203 v.player.Tick() 204 return nil 205 } 206 207 log.Debugf("Fetching new player information") 208 209 client, err := v.Spotify() 210 if err != nil { 211 return err 212 } 213 214 state, err := client.PlayerState() 215 if err != nil { 216 return err 217 } 218 219 currentID := spotify.ID(v.player.TrackRow.ID()) 220 221 v.player.Update(*state) 222 223 // If track changed, clear information about whether this song is liked or not 224 if state.Item == nil || currentID != state.Item.ID { 225 v.player.ClearLiked() 226 } 227 228 // If track changed, and is known, add the currently playing track to history 229 if state.Item != nil && currentID != state.Item.ID { 230 v.History().Add(spotify_tracklist.FullTrackRow(*state.Item)) 231 } 232 233 if v.player.LikedIsKnown() { 234 return nil 235 } 236 237 err = v.updateLiked() 238 if err != nil { 239 return fmt.Errorf("get liked status of current song: %s", err) 240 } 241 242 return nil 243} 244 245// KeyInput receives key input signals, checks the sequencer for key bindings, 246// and runs commands if key bindings are found. 247func (v *Visp) keyEventCommand(event tcell.Event) string { 248 ev, ok := event.(*tcell.EventKey) 249 if !ok { 250 return "" 251 } 252 253 contexts := commands.Contexts(v) 254 v.sequencer.KeyInput(ev, contexts) 255 match := v.sequencer.Match(contexts) 256 257 if match == nil { 258 return "" 259 } 260 261 log.Debugf("Input sequencer matches bind: '%s' -> '%s'", match.Sequence, match.Command) 262 263 return match.Command 264} 265 266// SourceDefaultConfig reads, parses, and executes the default config. 267func (v *Visp) SourceDefaultConfig() error { 268 reader := strings.NewReader(options.Defaults) 269 return v.SourceConfig(reader) 270} 271 272// SourceConfigFile reads, parses, and executes a config file. 273func (v *Visp) SourceConfigFile(path string) error { 274 file, err := os.Open(path) 275 if err != nil { 276 return err 277 } 278 defer file.Close() 279 log.Infof("Reading configuration file %s", path) 280 return v.SourceConfig(file) 281} 282 283// SourceConfig reads, parses, and executes config lines. 284func (v *Visp) SourceConfig(reader io.Reader) error { 285 scanner := bufio.NewScanner(reader) 286 for scanner.Scan() { 287 err := v.interpreter.Exec(scanner.Text()) 288 if err != nil { 289 return err 290 } 291 } 292 return nil 293} 294 295func (v *Visp) refreshToken() error { 296 server := options.GetString(options.SpotifyAuthServer) 297 client := &http.Client{ 298 Timeout: refreshTokenTimeout, 299 } 300 token, err := spotify_proxyclient.RefreshToken(server, client, v.Tokencache.Cached()) 301 if err != nil { 302 v.tokenRefresh = time.After(refreshTokenRetryInterval) 303 return err 304 } 305 return v.Authenticate(token) 306} 307 308func isSpotifyAccessTokenExpired(err error) bool { 309 match, _ := regexp.MatchString("access token", err.Error()) 310 return match 311} 312