Merge pull request #547 from dweymouth/lrclib-cache

Add cache for LrcLib lyrics
This commit is contained in:
Drew Weymouth
2025-02-06 09:12:30 -03:00
committed by GitHub
5 changed files with 109 additions and 13 deletions
+4
View File
@@ -48,6 +48,7 @@ type App struct {
MPRISHandler *MPRISHandler MPRISHandler *MPRISHandler
WinSMTC *SMTC WinSMTC *SMTC
ipcServer ipc.IPCServer ipcServer ipc.IPCServer
LrcLibFetcher *LrcLibFetcher
// UI callbacks to be set in main // UI callbacks to be set in main
OnReactivate func() OnReactivate func()
@@ -144,6 +145,9 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) { a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) {
_, _ = a.ImageManager.GetCoverThumbnail(coverID) _, _ = a.ImageManager.GetCoverThumbnail(coverID)
}) })
if a.Config.Application.EnableLrcLib {
a.LrcLibFetcher = NewLrcLibFetcher(a.cacheDir)
}
a.PlaybackManager.OnPlaying(func() { a.PlaybackManager.OnPlaying(func() {
SetSystemSleepDisabled(true) SetSystemSleepDisabled(true)
+94 -2
View File
@@ -2,20 +2,69 @@ package backend
import ( import (
"context" "context"
"crypto/md5"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/20after4/configdir"
"github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/mediaprovider"
) )
// FetchLrcLibLyrics is a static function to search and fetch lyrics from lrclib.net const lrclibCacheFolder = "lrclib"
func FetchLrcLibLyrics(name, artist, album string, durationSecs int) (*mediaprovider.Lyrics, error) {
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) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@@ -105,3 +154,46 @@ type lrcLibResponse struct {
PlainLyrics string `json:"plainLyrics"` PlainLyrics string `json:"plainLyrics"`
SyncedLyrics string `json:"syncedLyrics"` 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))
}
+4 -4
View File
@@ -150,10 +150,10 @@ type PlaylistWithTracks struct {
} }
type Lyrics struct { type Lyrics struct {
Title string Title string `json:"title"`
Artist string Artist string `json:"artist"`
Synced bool Synced bool `json:"synced"`
Lines []LyricLine Lines []LyricLine `json:"lines"`
} }
type LyricLine struct { type LyricLine struct {
+6 -6
View File
@@ -80,7 +80,7 @@ type nowPlayingPageState struct {
mp mediaprovider.MediaProvider mp mediaprovider.MediaProvider
canRate bool canRate bool
canShare bool canShare bool
lrcLib bool lrcFetch *backend.LrcLibFetcher
} }
func NewNowPlayingPage( func NewNowPlayingPage(
@@ -93,10 +93,10 @@ func NewNowPlayingPage(
mp mediaprovider.MediaProvider, mp mediaprovider.MediaProvider,
canRate bool, canRate bool,
canShare bool, canShare bool,
lrcLibEnabled bool, lrcLibFetcher *backend.LrcLibFetcher,
) *NowPlayingPage { ) *NowPlayingPage {
state := nowPlayingPageState{ 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 { if page, ok := pool.Obtain(util.WidgetTypeNowPlayingPage).(*NowPlayingPage); ok && page != nil {
page.nowPlayingPageState = state page.nowPlayingPageState = state
@@ -361,8 +361,8 @@ func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Tr
log.Printf("Error fetching lyrics: %v", err) log.Printf("Error fetching lyrics: %v", err)
} }
} }
if lyrics == nil { if lyrics == nil && a.lrcFetch != nil {
lyrics, err = backend.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, song.Duration) lyrics, err = a.lrcFetch.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, song.Duration)
if err != nil { if err != nil {
log.Println(err.Error()) log.Println(err.Error())
} }
@@ -450,7 +450,7 @@ func (a *NowPlayingPage) Reload() {
} }
func (s *nowPlayingPageState) Restore() Page { 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) var _ CanShowPlayTime = (*NowPlayingPage)(nil)
+1 -1
View File
@@ -48,7 +48,7 @@ func (r Router) CreatePage(rte controller.Route) Page {
case controller.Genres: case controller.Genres:
return NewGenresPage(r.Controller, r.App.ServerManager.Server) return NewGenresPage(r.Controller, r.App.ServerManager.Server)
case controller.NowPlaying: 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: case controller.Playlist:
return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.widgetPool, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager) return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.widgetPool, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager)
case controller.Playlists: case controller.Playlists: