From 29b04778f1dbb19f1d1d3bb4e7b01a00ecb52158 Mon Sep 17 00:00:00 2001 From: jojii Date: Wed, 5 Feb 2025 19:42:22 +0100 Subject: [PATCH 1/3] Add caching for lrclib lyrics --- backend/app.go | 6 +-- backend/lrclib.go | 80 ++++++++++++++++++++++++++++++++++ backend/mediaprovider/model.go | 8 ++-- ui/browsing/nowplayingpage.go | 8 ++-- ui/browsing/router.go | 2 +- 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/backend/app.go b/backend/app.go index 5a97309..13d413b 100644 --- a/backend/app.go +++ b/backend/app.go @@ -57,7 +57,7 @@ type App struct { displayAppName string appVersionTag string configDir string - cacheDir string + CacheDir string portableMode bool isFirstLaunch bool // set by config file reader @@ -103,7 +103,7 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas displayAppName: displayAppName, appVersionTag: appVersionTag, configDir: confDir, - cacheDir: cacheDir, + CacheDir: cacheDir, portableMode: portableMode, } a.bgrndCtx, a.cancel = context.WithCancel(context.Background()) @@ -411,7 +411,7 @@ func (a *App) LoginToDefaultServer(string) error { } func (a *App) DeleteServerCacheDir(serverID uuid.UUID) error { - path := path.Join(a.cacheDir, serverID.String()) + path := path.Join(a.CacheDir, serverID.String()) log.Printf("Deleting server cache dir: %s", path) return os.RemoveAll(path) } diff --git a/backend/lrclib.go b/backend/lrclib.go index 6277af4..4c652b9 100644 --- a/backend/lrclib.go +++ b/backend/lrclib.go @@ -2,10 +2,15 @@ package backend import ( "context" + "crypto/md5" + "encoding/hex" "encoding/json" "errors" "fmt" + "log" "net/http" + "os" + "path/filepath" "regexp" "strconv" "strings" @@ -14,6 +19,38 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" ) +func FetchLrcLibLyricsCached(name, artist, album string, durationSecs int, cacheDir string) (*mediaprovider.Lyrics, error) { + hash := makeTrackIdHash(name, artist, album) + cacheFilePath := filepath.Join(cacheDir, fmt.Sprintf("%s_lyrics.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 := FetchLrcLibLyrics(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 +} + // 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) @@ -105,3 +142,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) string { + hasher := md5.New() + identifier := fmt.Sprintf("%s;%s;%s", name, artist, album) + 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..8813c46 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -81,6 +81,7 @@ type nowPlayingPageState struct { canRate bool canShare bool lrcLib bool + cacheDir string } func NewNowPlayingPage( @@ -94,9 +95,10 @@ func NewNowPlayingPage( canRate bool, canShare bool, lrcLibEnabled bool, + cacheDir string, ) *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, lrcLib: lrcLibEnabled, cacheDir: cacheDir, } if page, ok := pool.Obtain(util.WidgetTypeNowPlayingPage).(*NowPlayingPage); ok && page != nil { page.nowPlayingPageState = state @@ -362,7 +364,7 @@ func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Tr } } if lyrics == nil { - lyrics, err = backend.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, song.Duration) + lyrics, err = backend.FetchLrcLibLyricsCached(song.Title, song.ArtistNames[0], song.Album, song.Duration, a.cacheDir) if err != nil { log.Println(err.Error()) } @@ -450,7 +452,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.lrcLib, s.cacheDir) } var _ CanShowPlayTime = (*NowPlayingPage)(nil) diff --git a/ui/browsing/router.go b/ui/browsing/router.go index cead13c..cfd3300 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.Config.Application.EnableLrcLib, r.App.CacheDir) 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: From 1eaa77928d32c2592c0459526d898dd96449f057 Mon Sep 17 00:00:00 2001 From: jojii Date: Wed, 5 Feb 2025 21:05:07 +0100 Subject: [PATCH 2/3] Review remarks --- backend/lrclib.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/lrclib.go b/backend/lrclib.go index 4c652b9..5be5c24 100644 --- a/backend/lrclib.go +++ b/backend/lrclib.go @@ -16,12 +16,17 @@ import ( "strings" "time" + "github.com/20after4/configdir" "github.com/dweymouth/supersonic/backend/mediaprovider" ) +const CACHE_LYRICS_FOLDER = "lyrics" + func FetchLrcLibLyricsCached(name, artist, album string, durationSecs int, cacheDir string) (*mediaprovider.Lyrics, error) { - hash := makeTrackIdHash(name, artist, album) - cacheFilePath := filepath.Join(cacheDir, fmt.Sprintf("%s_lyrics.txt", hash)) + hash := makeTrackIdHash(name, artist, album, durationSecs) + cachePath := filepath.Join(cacheDir, CACHE_LYRICS_FOLDER) + configdir.MakePath(cachePath) + cacheFilePath := filepath.Join(cachePath, fmt.Sprintf("%s_lyrics.txt", hash)) // File is cached. Try to use it if _, err := os.Stat(cacheFilePath); err == nil { @@ -179,9 +184,9 @@ func readCachedLyrics(cacheFile string) (*mediaprovider.Lyrics, error) { } // Create a "unique" hash for a song to identify it. -func makeTrackIdHash(name, artist, album string) string { +func makeTrackIdHash(name, artist, album string, durationSecs int) string { hasher := md5.New() - identifier := fmt.Sprintf("%s;%s;%s", name, artist, album) + identifier := fmt.Sprintf("%s;%s;%s;%d", name, artist, album, durationSecs) hasher.Write([]byte(identifier)) return hex.EncodeToString(hasher.Sum(nil)) } From 6d5af7e8411eaf429267d876555ffb4cbb1c1e44 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 6 Feb 2025 08:53:38 -0300 Subject: [PATCH 3/3] refactor --- backend/app.go | 10 +++++++--- backend/lrclib.go | 23 +++++++++++++++-------- ui/browsing/nowplayingpage.go | 14 ++++++-------- ui/browsing/router.go | 2 +- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/backend/app.go b/backend/app.go index 13d413b..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() @@ -57,7 +58,7 @@ type App struct { displayAppName string appVersionTag string configDir string - CacheDir string + cacheDir string portableMode bool isFirstLaunch bool // set by config file reader @@ -103,7 +104,7 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas displayAppName: displayAppName, appVersionTag: appVersionTag, configDir: confDir, - CacheDir: cacheDir, + cacheDir: cacheDir, portableMode: portableMode, } a.bgrndCtx, a.cancel = context.WithCancel(context.Background()) @@ -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) @@ -411,7 +415,7 @@ func (a *App) LoginToDefaultServer(string) error { } func (a *App) DeleteServerCacheDir(serverID uuid.UUID) error { - path := path.Join(a.CacheDir, serverID.String()) + path := path.Join(a.cacheDir, serverID.String()) log.Printf("Deleting server cache dir: %s", path) return os.RemoveAll(path) } diff --git a/backend/lrclib.go b/backend/lrclib.go index 5be5c24..11f215e 100644 --- a/backend/lrclib.go +++ b/backend/lrclib.go @@ -20,13 +20,21 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" ) -const CACHE_LYRICS_FOLDER = "lyrics" +const lrclibCacheFolder = "lrclib" -func FetchLrcLibLyricsCached(name, artist, album string, durationSecs int, cacheDir string) (*mediaprovider.Lyrics, error) { - hash := makeTrackIdHash(name, artist, album, durationSecs) - cachePath := filepath.Join(cacheDir, CACHE_LYRICS_FOLDER) +type LrcLibFetcher struct { + cachePath string +} + +func NewLrcLibFetcher(baseCacheDir string) *LrcLibFetcher { + cachePath := filepath.Join(baseCacheDir, lrclibCacheFolder) configdir.MakePath(cachePath) - cacheFilePath := filepath.Join(cachePath, fmt.Sprintf("%s_lyrics.txt", hash)) + 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 { @@ -42,7 +50,7 @@ func FetchLrcLibLyricsCached(name, artist, album string, durationSecs int, cache } // Fetch the lyrics - lyrics, err := FetchLrcLibLyrics(name, artist, album, durationSecs) + lyrics, err := l.fetchFromServer(name, artist, album, durationSecs) if err != nil { return nil, err } @@ -56,8 +64,7 @@ func FetchLrcLibLyricsCached(name, artist, album string, durationSecs int, cache return lyrics, nil } -// FetchLrcLibLyrics is a static function to search and fetch lyrics from lrclib.net -func FetchLrcLibLyrics(name, artist, album string, durationSecs int) (*mediaprovider.Lyrics, error) { +func (l *LrcLibFetcher) fetchFromServer(name, artist, album string, durationSecs int) (*mediaprovider.Lyrics, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 8813c46..c77a459 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -80,8 +80,7 @@ type nowPlayingPageState struct { mp mediaprovider.MediaProvider canRate bool canShare bool - lrcLib bool - cacheDir string + lrcFetch *backend.LrcLibFetcher } func NewNowPlayingPage( @@ -94,11 +93,10 @@ func NewNowPlayingPage( mp mediaprovider.MediaProvider, canRate bool, canShare bool, - lrcLibEnabled bool, - cacheDir string, + 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, cacheDir: cacheDir, + 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 @@ -363,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.FetchLrcLibLyricsCached(song.Title, song.ArtistNames[0], song.Album, song.Duration, a.cacheDir) + 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()) } @@ -452,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, s.cacheDir) + 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 cfd3300..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, r.App.CacheDir) + 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: