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