diff --git a/backend/config.go b/backend/config.go index 1c980f5..0b1fe9a 100644 --- a/backend/config.go +++ b/backend/config.go @@ -45,6 +45,7 @@ type AppConfig struct { SaveQueueToServer bool DefaultPlaylistID string ShowTrackChangeNotification bool + EnableLrcLib bool // Experimental - may be removed in future FontNormalTTF string @@ -159,6 +160,7 @@ func DefaultConfig(appVersionTag string) *Config { SavePlayQueue: true, SaveQueueToServer: false, ShowTrackChangeNotification: false, + EnableLrcLib: true, }, AlbumPage: AlbumPageConfig{ TracklistColumns: []string{"Artist", "Time", "Plays", "Favorite", "Rating"}, diff --git a/backend/lrclib.go b/backend/lrclib.go new file mode 100644 index 0000000..6277af4 --- /dev/null +++ b/backend/lrclib.go @@ -0,0 +1,107 @@ +package backend + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/dweymouth/supersonic/backend/mediaprovider" +) + +// FetchLrcLibLyrics is a static function to search and fetch lyrics from lrclib.net +func FetchLrcLibLyrics(name, artist, album string, durationSecs int) (*mediaprovider.Lyrics, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://lrclib.net/api/get", nil) + if err != nil { + return nil, err + } + req.Header.Add("Accept", "application/json") + req.Header.Add("User-Agent", "Supersonic") + + q := req.URL.Query() + q.Add("track_name", name) + q.Add("artist_name", artist) + q.Add("album_name", album) + q.Add("duration", strconv.Itoa(durationSecs)) + req.URL.RawQuery = q.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return parseLrcLibResponse(resp) +} + +func parseLrcLibResponse(resp *http.Response) (*mediaprovider.Lyrics, error) { + if resp.StatusCode == http.StatusNotFound { + return nil, errors.New("lrclib lyrics not found") + } else if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("error from lrclib: status %d", resp.StatusCode) + } + + var parsedResponse lrcLibResponse + if err := json.NewDecoder(resp.Body).Decode(&parsedResponse); err != nil { + return nil, fmt.Errorf("failed to decode lrclib response: %w", err) + } + lrcs := &mediaprovider.Lyrics{ + Title: parsedResponse.TrackName, + Artist: parsedResponse.ArtistName, + } + if parsedResponse.SyncedLyrics != "" { + lines, err := parseSyncedLyrics(parsedResponse.SyncedLyrics) + if err != nil { + return nil, err + } + lrcs.Synced = true + lrcs.Lines = lines + } else { + for _, line := range strings.Split(parsedResponse.PlainLyrics, "\n") { + lrcs.Lines = append(lrcs.Lines, mediaprovider.LyricLine{Text: line}) + } + } + return lrcs, nil +} + +var syncedRegex = regexp.MustCompile(`^\[(\d\d):(\d\d\.\d\d\d?)\] ?(.+)$`) + +func parseSyncedLyrics(synced string) ([]mediaprovider.LyricLine, error) { + var lines []mediaprovider.LyricLine + for _, line := range strings.Split(synced, "\n") { + matches := syncedRegex.FindStringSubmatch(line) + if len(matches) != 4 { + continue // malformed lyric line, attempt to continue + } + min, _ := strconv.Atoi(matches[1]) + sec, _ := strconv.ParseFloat(matches[2], 64) + lines = append(lines, mediaprovider.LyricLine{ + Start: float64(min)*60 + sec, + Text: matches[3], + }) + } + var err error + if len(lines) == 0 { + err = errors.New("failed to parse synced lyrics") + } + return lines, err +} + +type lrcLibResponse struct { + ID int `json:"id"` + TrackName string `json:"trackName"` + ArtistName string `json:"artistName"` + AlbumName string `json:"albumName"` + Duration float64 `json:"duration"` + Instrumental bool `json:"instrumental"` + PlainLyrics string `json:"plainLyrics"` + SyncedLyrics string `json:"syncedLyrics"` +} diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 95fab3b..1b7c81e 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -70,6 +70,7 @@ type nowPlayingPageState struct { mp mediaprovider.MediaProvider canRate bool canShare bool + lrcLib bool } func NewNowPlayingPage( @@ -82,9 +83,10 @@ func NewNowPlayingPage( mp mediaprovider.MediaProvider, canRate bool, canShare bool, + lrcLibEnabled bool, ) *NowPlayingPage { a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{ - conf: conf, contr: contr, pool: pool, sm: sm, im: im, pm: pm, mp: mp, canRate: canRate, canShare: canShare, + conf: conf, contr: contr, pool: pool, sm: sm, im: im, pm: pm, mp: mp, canRate: canRate, canShare: canShare, lrcLib: lrcLibEnabled, }} a.ExtendBaseWidget(a) @@ -275,27 +277,35 @@ func (a *NowPlayingPage) updateLyrics() { return } a.curLyricsID = a.nowPlayingID + ctx, cancel := context.WithCancel(context.Background()) + a.lyricFetchCancel = cancel + go a.fetchLyrics(ctx, a.nowPlaying) +} + +func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Track) { + var lyrics *mediaprovider.Lyrics + var err error if lp, ok := a.sm.Server.(mediaprovider.LyricsProvider); ok { - ctx, cancel := context.WithCancel(context.Background()) - a.lyricFetchCancel = cancel - go func(ctx context.Context) { - var lyrics *mediaprovider.Lyrics - var err error - if lyrics, err = lp.GetLyrics(a.nowPlaying); err != nil { - log.Printf("Error fetching lyrics: %v", err) - } - select { - case <-ctx.Done(): - return - default: - a.lyricLock.Lock() - a.lyricsViewer.SetLyrics(lyrics) - a.lyricsViewer.OnSeeked(a.lastPlayPos) - a.lyricLock.Unlock() - } - }(ctx) - } else { - a.lyricsViewer.SetLyrics(nil) + if lyrics, err = lp.GetLyrics(a.nowPlaying); err != nil { + log.Printf("Error fetching lyrics: %v", err) + } + } + if lyrics == nil { + lyrics, err = backend.FetchLrcLibLyrics(song.Name, song.ArtistNames[0], song.Album, song.Duration) + if err != nil { + log.Println(err.Error()) + } + } + select { + case <-ctx.Done(): + return + default: + a.lyricLock.Lock() + a.lyricsViewer.SetLyrics(lyrics) + if lyrics != nil { + a.lyricsViewer.OnSeeked(a.lastPlayPos) + } + a.lyricLock.Unlock() } } @@ -352,7 +362,7 @@ func (s *nowPlayingPageState) Restore() Page { page.Reload() return page } - return NewNowPlayingPage(s.conf, s.contr, s.pool, s.sm, s.im, s.pm, s.mp, s.canRate, s.canShare) + return NewNowPlayingPage(s.conf, s.contr, s.pool, s.sm, s.im, s.pm, s.mp, s.canRate, s.canShare, s.lrcLib) } var _ CanShowPlayTime = (*NowPlayingPage)(nil) diff --git a/ui/browsing/router.go b/ui/browsing/router.go index 9c666fe..d8967c0 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -48,7 +48,7 @@ func (r Router) CreatePage(rte controller.Route) Page { case controller.Genres: return NewGenresPage(r.Controller, r.App.ServerManager.Server) case controller.NowPlaying: - return NewNowPlayingPage(&r.App.Config.NowPlayingConfig, r.Controller, r.widgetPool, r.App.ServerManager, r.App.ImageManager, r.App.PlaybackManager, r.App.ServerManager.Server, canRate, canShare) + return NewNowPlayingPage(&r.App.Config.NowPlayingConfig, r.Controller, r.widgetPool, r.App.ServerManager, r.App.ImageManager, r.App.PlaybackManager, r.App.ServerManager.Server, canRate, canShare, r.App.Config.Application.EnableLrcLib) case controller.Playlist: return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.widgetPool, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager) case controller.Playlists: