diff --git a/backend/app.go b/backend/app.go index 5a97309..4ff7815 100644 --- a/backend/app.go +++ b/backend/app.go @@ -48,6 +48,7 @@ type App struct { MPRISHandler *MPRISHandler WinSMTC *SMTC ipcServer ipc.IPCServer + LrcLibFetcher *LrcLibFetcher // UI callbacks to be set in main OnReactivate func() @@ -144,6 +145,9 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) { _, _ = a.ImageManager.GetCoverThumbnail(coverID) }) + if a.Config.Application.EnableLrcLib { + a.LrcLibFetcher = NewLrcLibFetcher(a.cacheDir) + } a.PlaybackManager.OnPlaying(func() { SetSystemSleepDisabled(true) diff --git a/backend/lrclib.go b/backend/lrclib.go index 6277af4..11f215e 100644 --- a/backend/lrclib.go +++ b/backend/lrclib.go @@ -2,20 +2,69 @@ package backend import ( "context" + "crypto/md5" + "encoding/hex" "encoding/json" "errors" "fmt" + "log" "net/http" + "os" + "path/filepath" "regexp" "strconv" "strings" "time" + "github.com/20after4/configdir" "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) { +const lrclibCacheFolder = "lrclib" + +type LrcLibFetcher struct { + cachePath string +} + +func NewLrcLibFetcher(baseCacheDir string) *LrcLibFetcher { + cachePath := filepath.Join(baseCacheDir, lrclibCacheFolder) + configdir.MakePath(cachePath) + return &LrcLibFetcher{cachePath: cachePath} +} + +func (l *LrcLibFetcher) FetchLrcLibLyrics(name, artist, album string, durationSecs int) (*mediaprovider.Lyrics, error) { + hash := makeTrackIdHash(name, artist, album, durationSecs) + cacheFilePath := filepath.Join(l.cachePath, fmt.Sprintf("%s.txt", hash)) + + // File is cached. Try to use it + if _, err := os.Stat(cacheFilePath); err == nil { + lyrics, err := readCachedLyrics(cacheFilePath) + if err == nil { + return lyrics, nil + } + + // On an error, remove the file. + if !os.IsNotExist(err) { + os.Remove(cacheFilePath) + } + } + + // Fetch the lyrics + lyrics, err := l.fetchFromServer(name, artist, album, durationSecs) + if err != nil { + return nil, err + } + + // Try to write it into cache + err = writeCachedLyrics(cacheFilePath, lyrics) + if err != nil { + log.Printf("Failed to serialize fetched lyrics: %s", err) + } + + return lyrics, nil +} + +func (l *LrcLibFetcher) fetchFromServer(name, artist, album string, durationSecs int) (*mediaprovider.Lyrics, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -105,3 +154,46 @@ type lrcLibResponse struct { PlainLyrics string `json:"plainLyrics"` SyncedLyrics string `json:"syncedLyrics"` } + +// Write lyrics to the given file. +func writeCachedLyrics(cacheFile string, lyrics *mediaprovider.Lyrics) error { + serialized, err := json.Marshal(lyrics) + if err != nil { + return err + } + + f, err := os.Create(cacheFile) + if err != nil { + return nil + } + defer f.Close() + + f.Write(serialized) + + return nil +} + +// Read lyrics from the given cache file. +func readCachedLyrics(cacheFile string) (*mediaprovider.Lyrics, error) { + cachedBytes, err := os.ReadFile(cacheFile) + if err != nil { + return nil, err + } + + var lyrics mediaprovider.Lyrics + + err = json.Unmarshal(cachedBytes, &lyrics) + if err != nil { + return nil, err + } + + return &lyrics, nil +} + +// Create a "unique" hash for a song to identify it. +func makeTrackIdHash(name, artist, album string, durationSecs int) string { + hasher := md5.New() + identifier := fmt.Sprintf("%s;%s;%s;%d", name, artist, album, durationSecs) + hasher.Write([]byte(identifier)) + return hex.EncodeToString(hasher.Sum(nil)) +} diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index b2ec9d7..4c65c80 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -150,10 +150,10 @@ type PlaylistWithTracks struct { } type Lyrics struct { - Title string - Artist string - Synced bool - Lines []LyricLine + Title string `json:"title"` + Artist string `json:"artist"` + Synced bool `json:"synced"` + Lines []LyricLine `json:"lines"` } type LyricLine struct { diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 0c6eab7..c77a459 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -80,7 +80,7 @@ type nowPlayingPageState struct { mp mediaprovider.MediaProvider canRate bool canShare bool - lrcLib bool + lrcFetch *backend.LrcLibFetcher } func NewNowPlayingPage( @@ -93,10 +93,10 @@ func NewNowPlayingPage( mp mediaprovider.MediaProvider, canRate bool, canShare bool, - lrcLibEnabled bool, + lrcLibFetcher *backend.LrcLibFetcher, ) *NowPlayingPage { state := nowPlayingPageState{ - conf: conf, contr: contr, pool: pool, sm: sm, im: im, pm: pm, mp: mp, canRate: canRate, canShare: canShare, lrcLib: lrcLibEnabled, + conf: conf, contr: contr, pool: pool, sm: sm, im: im, pm: pm, mp: mp, canRate: canRate, canShare: canShare, lrcFetch: lrcLibFetcher, } if page, ok := pool.Obtain(util.WidgetTypeNowPlayingPage).(*NowPlayingPage); ok && page != nil { page.nowPlayingPageState = state @@ -361,8 +361,8 @@ func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Tr log.Printf("Error fetching lyrics: %v", err) } } - if lyrics == nil { - lyrics, err = backend.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, song.Duration) + if lyrics == nil && a.lrcFetch != nil { + lyrics, err = a.lrcFetch.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, song.Duration) if err != nil { log.Println(err.Error()) } @@ -450,7 +450,7 @@ func (a *NowPlayingPage) Reload() { } func (s *nowPlayingPageState) Restore() Page { - return NewNowPlayingPage(s.conf, s.contr, s.pool, s.sm, s.im, s.pm, s.mp, s.canRate, s.canShare, s.lrcLib) + return NewNowPlayingPage(s.conf, s.contr, s.pool, s.sm, s.im, s.pm, s.mp, s.canRate, s.canShare, s.lrcFetch) } var _ CanShowPlayTime = (*NowPlayingPage)(nil) diff --git a/ui/browsing/router.go b/ui/browsing/router.go index cead13c..80e631d 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, r.App.Config.Application.EnableLrcLib) + 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.LrcLibFetcher) 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: