From 30a589890667ed0af39b9c85ad9891750ef208c0 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Wed, 18 Dec 2024 15:42:25 -0600 Subject: [PATCH] adds: support for synchronized lyrics (as provided by gonic & Navidrome) adds: ability to also write logs to a log file --- event_loop.go | 31 ++++++++++++++++++++++++++ logger/logger.go | 35 ++++++++++++++++++++++++++--- page_playlist.go | 6 +++++ page_queue.go | 23 ++++++++++--------- stmps.go | 4 +++- subsonic/api.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 14 deletions(-) diff --git a/event_loop.go b/event_loop.go index ded9789..f85335a 100644 --- a/event_loop.go +++ b/event_loop.go @@ -65,12 +65,38 @@ func (ui *Ui) guiEventLoop() { ui.app.QueueUpdateDraw(func() { ui.playerStatus.SetText(formatPlayerStatus(statusData.Volume, statusData.Position, statusData.Duration)) + cl := ui.queuePage.currentLyrics.Lines + lcl := len(cl) + if lcl == 0 { + ui.queuePage.lyrics.SetText("\n[::i]No lyrics[-:-:-]") + } else { + p := statusData.Position * 1000 + _, _, _, fh := ui.queuePage.lyrics.GetInnerRect() + ui.logger.Printf("field height is %d", fh) + for i := 0; i < lcl-1; i++ { + if p >= cl[i].Start && p < cl[i+1].Start { + txt := "" + if i > 1 { + txt = cl[i-2].Value + "\n" + } + if i > 0 { + txt += "[::b]" + cl[i-1].Value + "[-:-:-]\n" + } + for k := i; k < lcl && k-i < fh; k++ { + txt += cl[k].Value + "\n" + } + ui.queuePage.lyrics.SetText(txt) + break + } + } + } }) case mpvplayer.EventStopped: ui.logger.Print("mpvEvent: stopped") ui.app.QueueUpdateDraw(func() { ui.startStopStatus.SetText("[red::b]Stopped[::-]") + ui.queuePage.lyrics.SetText("") ui.queuePage.UpdateQueue() }) @@ -115,6 +141,11 @@ func (ui *Ui) guiEventLoop() { ui.app.QueueUpdateDraw(func() { ui.startStopStatus.SetText(statusText) ui.queuePage.UpdateQueue() + if len(ui.queuePage.currentLyrics.Lines) == 0 { + ui.queuePage.lyrics.SetText("\n[::i]No lyrics[-:-:-]") + } else { + ui.queuePage.lyrics.SetText("") + } }) case mpvplayer.EventPaused: diff --git a/logger/logger.go b/logger/logger.go index d5d317b..08046df 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -3,24 +3,53 @@ package logger -import "fmt" +import ( + "fmt" + "io" + "os" +) type Logger struct { Prints chan string + fout io.WriteCloser } -func Init() *Logger { - return &Logger{make(chan string, 100)} +func Init(file string) *Logger { + l := Logger{ + Prints: make(chan string, 100), + } + if file != "" { + var err error + l.fout, err = os.Create(file) + if err != nil { + fmt.Printf("error opening requested log file %q\n", file) + } + } + return &l } func (l *Logger) Print(s string) { + if l.fout != nil { + fmt.Fprintf(l.fout, "%s\n", s) + } l.Prints <- s } func (l *Logger) Printf(s string, as ...interface{}) { + if l.fout != nil { + fmt.Fprintf(l.fout, s, as...) + fmt.Fprintf(l.fout, "\n") + } l.Prints <- fmt.Sprintf(s, as...) } func (l *Logger) PrintError(source string, err error) { l.Printf("Error(%s) -> %s", source, err.Error()) } + +func (l *Logger) Close() error { + if l.fout != nil { + return l.fout.Close() + } + return nil +} diff --git a/page_playlist.go b/page_playlist.go index 4ece28b..186c04a 100644 --- a/page_playlist.go +++ b/page_playlist.go @@ -258,6 +258,12 @@ func (p *PlaylistPage) UpdatePlaylists() { } p.updatingMutex.Lock() defer p.updatingMutex.Unlock() + if response == nil { + p.logger.Printf("error: GetPlaylists response is nil") + p.isUpdating = false + stop <- true + return + } p.ui.playlists = response.Playlists.Playlists p.ui.app.QueueUpdateDraw(func() { p.playlistList.Clear() diff --git a/page_queue.go b/page_queue.go index 4a26271..273eb44 100644 --- a/page_queue.go +++ b/page_queue.go @@ -49,7 +49,7 @@ type QueuePage struct { lyrics *tview.TextView coverArt *tview.Image - changeLyrics chan string + currentLyrics subsonic.StructuredLyrics // external refs ui *Ui @@ -157,6 +157,9 @@ func (ui *Ui) createQueuePage() *QueuePage { queuePage.lyrics.SetTitle(" lyrics ") queuePage.lyrics.SetTitleAlign(tview.AlignCenter) queuePage.lyrics.SetDynamicColors(true).SetScrollable(true) + queuePage.lyrics.SetWrap(true) + queuePage.lyrics.SetWordWrap(true) + queuePage.lyrics.SetBorderPadding(1, 1, 1, 1) queuePage.queueList.SetSelectionChangedFunc(queuePage.changeSelection) @@ -180,15 +183,6 @@ func (ui *Ui) createQueuePage() *QueuePage { starIdList: ui.starIdList, } - go func() { - for { - select { - case songId := <-queuePage.changeLyrics: - // queuePage.connection.GetLyrics(songId) - } - } - }() - return &queuePage } @@ -212,6 +206,15 @@ func (q *QueuePage) changeSelection(row, column int) { } } q.coverArt.SetImage(art) + lyrics, err := q.ui.connection.GetLyricsBySongId(currentSong.Id) + if err != nil { + q.logger.Printf("error fetching lyrics for %s: %v", currentSong.Title, err) + } else if len(lyrics) > 0 { + q.logger.Printf("got lyrics for %s", currentSong.Title) + q.currentLyrics = lyrics[0] + } else { + q.currentLyrics = subsonic.StructuredLyrics{Lines: []subsonic.LyricsLine{}} + } _ = q.songInfoTemplate.Execute(q.songInfo, currentSong) } diff --git a/stmps.go b/stmps.go index 722c63a..128b3c5 100644 --- a/stmps.go +++ b/stmps.go @@ -113,6 +113,7 @@ func main() { memprofile := flag.String("memprofile", "", "write memory profile to `file`") configFile := flag.String("config", "", "use config `file`") version := flag.Bool("version", false, "print the stmps version and exit") + logFile := flag.String("log", "", "also write logs to this file") flag.Parse() if *help { @@ -157,7 +158,8 @@ func main() { osExit(2) } - logger := logger.Init() + logger := logger.Init(*logFile) + defer logger.Close() initCommandHandler(logger) // init mpv engine diff --git a/subsonic/api.go b/subsonic/api.go index 9c64041..3a49cb6 100644 --- a/subsonic/api.go +++ b/subsonic/api.go @@ -278,6 +278,7 @@ type SubsonicResponse struct { SearchResults SubsonicResults `json:"searchResult3"` ScanStatus ScanStatus `json:"scanStatus"` PlayQueue PlayQueue `json:"playQueue"` + LyricsList LyricsList `json:"lyricsList"` } type responseWrapper struct { @@ -440,6 +441,48 @@ func (connection *SubsonicConnection) GetCoverArt(id string) (image.Image, error return art, err } +// GetLyricsBySongId fetches time synchronized song lyrics. If the server does +// not support this, an error is returned. +func (connection *SubsonicConnection) GetLyricsBySongId(id string) ([]StructuredLyrics, error) { + if id == "" { + return []StructuredLyrics{}, fmt.Errorf("GetLyricsBySongId: no ID provided") + } + query := defaultQuery(connection) + query.Set("id", id) + query.Set("f", "json") + caller := "GetLyricsBySongId" + res, err := http.Get(connection.Host + "/rest/getLyricsBySongId" + "?" + query.Encode()) + if err != nil { + return []StructuredLyrics{}, fmt.Errorf("[%s] failed to make GET request: %v", caller, err) + } + + if res.Body != nil { + defer res.Body.Close() + } else { + return []StructuredLyrics{}, fmt.Errorf("[%s] response body is nil", caller) + } + + if res.StatusCode != http.StatusOK { + return []StructuredLyrics{}, fmt.Errorf("[%s] unexpected status code: %d, status: %s", caller, res.StatusCode, res.Status) + } + + if len(res.Header["Content-Type"]) == 0 { + return []StructuredLyrics{}, fmt.Errorf("[%s] unknown image type (no content-type from server)", caller) + } + + responseBody, readErr := io.ReadAll(res.Body) + if readErr != nil { + return []StructuredLyrics{}, fmt.Errorf("[%s] failed to read response body: %v", caller, readErr) + } + + var decodedBody responseWrapper + err = json.Unmarshal(responseBody, &decodedBody) + if err != nil { + return []StructuredLyrics{}, fmt.Errorf("[%s] failed to unmarshal response body: %v", caller, err) + } + return decodedBody.Response.LyricsList.StructuredLyrics, nil +} + func (connection *SubsonicConnection) GetRandomSongs(Id string, randomType string) (*SubsonicResponse, error) { query := defaultQuery(connection) @@ -688,3 +731,18 @@ func (connection *SubsonicConnection) LoadPlayQueue() (*SubsonicResponse, error) requestUrl := fmt.Sprintf("%s/rest/getPlayQueue?%s", connection.Host, query.Encode()) return connection.getResponse("GetPlayQueue", requestUrl) } + +type LyricsList struct { + StructuredLyrics []StructuredLyrics `json:"structuredLyrics"` +} + +type StructuredLyrics struct { + Lang string `json:"lang"` + Synced bool `json:"synced"` + Lines []LyricsLine `json:"line"` +} + +type LyricsLine struct { + Start int64 `json:"start"` + Value string `json:"value"` +}