diff --git a/README.md b/README.md index b96ff54..e268364 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,9 @@ browser: ### Local lyrics source ### local: + # Enable the local lyrics source. + # For backwards compatibility reasons setting the folder also enables this source. + enabled: false # Folder for scanning .lrc files. Example: "~/Music". folder: "" ``` @@ -226,10 +229,17 @@ You need to install a [browser extension](https://wnp.keifufu.dev/extension/gett ```yaml # config.yaml local: + enabled: true folder: "" ``` -If you want to use your local collection of `.lrc` files to display lyrics, specify the folder to scan. The application will use files with the most similar name. All other lyrics sources will be disabled. +Display lyrics from local `.lrc` files. + +By default, the application will look for a file that is a sibling of a local music file (e.g. local player via mpris), i.e. with the same path, with the extension replaced by `.lrc`. + +If the `folder` config option is set, it will additionally search for files within that folder. If the player provides a relative path to the music file (e.g. mpd), an exact match is attempted first as described above. If that fails, a best-effort search will be performed, returning a `.lrc` file in the folder (can be nested) with the most similar name. + +All other lyrics sources will be disabled. ## Information diff --git a/cmd/root.go b/cmd/root.go index b05a43a..7dc342a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -141,7 +141,8 @@ func loadPlayer(conf *config.Config) (player.Player, error) { } func loadProvider(conf *config.Config, player player.Player) (lyrics.Provider, error) { - if conf.Local.Folder != "" { + // For backwards compatibility reasons, this is auto-enabled when Folder is set + if conf.Local.Enabled || conf.Local.Folder != "" { return local.New(conf.Local.Folder) } if conf.Cookie == "" { diff --git a/config/config.go b/config/config.go index e67cebd..0925588 100644 --- a/config/config.go +++ b/config/config.go @@ -69,6 +69,7 @@ type Config struct { } `yaml:"browser"` Local struct { + Enabled bool `default: "false" yaml:"enabled"` Folder string `yaml:"folder"` } `yaml:"local"` } diff --git a/lyrics/lyrics.go b/lyrics/lyrics.go index a1507fe..e5304c7 100644 --- a/lyrics/lyrics.go +++ b/lyrics/lyrics.go @@ -1,7 +1,9 @@ package lyrics +import "sptlrx/player" + type Provider interface { - Lyrics(id, query string) ([]Line, error) + Lyrics(track *player.TrackMetadata) ([]Line, error) } type Line struct { diff --git a/player/player.go b/player/player.go index a6ae10d..c7510da 100644 --- a/player/player.go +++ b/player/player.go @@ -1,14 +1,22 @@ package player +import "net/url" + type Player interface { State() (*State, error) } -type State struct { +type TrackMetadata struct { // ID of the current track. ID string + // URI to music file, if it is known. May be a (local) relative path. + Uri *url.URL // Query is a string that can be used to find lyrics. Query string +} + +type State struct { + Track TrackMetadata // Position of the current track in ms. Position int // Playing means whether the track is playing at the moment. diff --git a/pool/pool.go b/pool/pool.go index ecaea9c..dbec1a5 100644 --- a/pool/pool.go +++ b/pool/pool.go @@ -44,10 +44,10 @@ func Listen( case newState := <-stateCh: lastUpdate = time.Now() - if newState.ID != state.ID { + if newState.Track.ID != state.Track.ID { changed = true - if newState.ID != "" { - newLines, err := provider.Lyrics(newState.ID, newState.Query) + if newState.Track.ID != "" { + newLines, err := provider.Lyrics(&newState.Track) if err != nil { state.Err = err } @@ -99,8 +99,7 @@ func listenPlayer(player player.Player, ch chan playerState, interval int) { st := playerState{Err: err} if state != nil { - st.ID = state.ID - st.Query = state.Query + st.Track = state.Track st.Playing = state.Playing st.Position = state.Position } diff --git a/services/browser/browser.go b/services/browser/browser.go index 693b0fd..f39f8e5 100644 --- a/services/browser/browser.go +++ b/services/browser/browser.go @@ -153,8 +153,10 @@ func (c *Client) State() (*player.State, error) { position += int(time.Since(c.updateTime).Milliseconds()) } return &player.State{ - ID: query, - Query: query, + Track: player.TrackMetadata{ + ID: query, + Query: query, + }, Position: position, Playing: c.state == playing, }, nil diff --git a/services/hosted/hosted.go b/services/hosted/hosted.go index 3d17b8b..ea0a7a2 100644 --- a/services/hosted/hosted.go +++ b/services/hosted/hosted.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "sptlrx/lyrics" + "sptlrx/player" ) // Host your own: https://github.com/raitonoberu/lyricsapi @@ -20,8 +21,8 @@ type Client struct { host string } -func (c *Client) Lyrics(id, query string) ([]lyrics.Line, error) { - var url = fmt.Sprintf("https://%s/api/lyrics?name=%s", c.host, url.QueryEscape(query)) +func (c *Client) Lyrics(track *player.TrackMetadata) ([]lyrics.Line, error) { + var url = fmt.Sprintf("https://%s/api/lyrics?name=%s", c.host, url.QueryEscape(track.Query)) req, _ := http.NewRequest("GET", url, nil) resp, err := http.DefaultClient.Do(req) diff --git a/services/local/local.go b/services/local/local.go index 163028c..6770653 100644 --- a/services/local/local.go +++ b/services/local/local.go @@ -5,9 +5,11 @@ import ( "fmt" "io" "io/fs" + "net/url" "os" "path/filepath" "sptlrx/lyrics" + "sptlrx/player" "strconv" "strings" ) @@ -26,25 +28,34 @@ type file struct { } func New(folder string) (*Client, error) { - index, err := createIndex(folder) + var expandedFolder string + if strings.HasPrefix(folder, "~/") { + dirname, _ := os.UserHomeDir() + expandedFolder = filepath.Join(dirname, folder[2:]) + } else { + expandedFolder = folder + } + + index, err := createIndex(expandedFolder) if err != nil { return nil, err } - return &Client{index: index}, nil + return &Client{folder: expandedFolder, index: index}, nil } // Client implements lyrics.Provider type Client struct { + folder string index []*file } -func (c *Client) Lyrics(id, query string) ([]lyrics.Line, error) { - f := c.findFile(query) - if f == nil { +func (c *Client) Lyrics(track *player.TrackMetadata) ([]lyrics.Line, error) { + f := c.findFile(track) + if f == "" { return nil, nil } - reader, err := os.Open(f.Path) + reader, err := os.Open(f) if err != nil { return nil, err } @@ -53,8 +64,19 @@ func (c *Client) Lyrics(id, query string) ([]lyrics.Line, error) { return parseLrcFile(reader), nil } -func (c *Client) findFile(query string) *file { - parts := splitString(query) +func (c *Client) findFile(track *player.TrackMetadata) string { + if track == nil { + return "" + } + + // If it is a local track, try for similarly named .lrc file first + var exactMatch string = c.fileByLocalUri(track.Uri) + if exactMatch != "" { + return exactMatch + } + + // Fall back to best-effort search + parts := splitString(track.Query) var best *file var maxScore int @@ -76,16 +98,43 @@ func (c *Client) findFile(query string) *file { } } } - return best + if best == nil { + return "" + } + return best.Path } -func createIndex(folder string) ([]*file, error) { - if strings.HasPrefix(folder, "~/") { - dirname, _ := os.UserHomeDir() - folder = filepath.Join(dirname, folder[2:]) +func (c *Client) fileByLocalUri(uri *url.URL) string { + if uri == nil { + return "" + } + if uri.Scheme != "file" && uri.Scheme != "" { + return "" } + var absUri string + if filepath.IsAbs(uri.Path) { + // uri is already absolute + absUri = uri.Path + } else if c.folder != "" { + // Uri is relative to local music directory + absUri = filepath.Join(c.folder, uri.Path) + } else { + // Can not handle relative uri without folder configured + return "" + } + absLyricsUri := strings.TrimSuffix(absUri, filepath.Ext(absUri)) + ".lrc" + _, err := os.Stat(absLyricsUri) + if err != nil { + return "" + } + return absLyricsUri +} +func createIndex(folder string) ([]*file, error) { index := []*file{} + if folder == "" { + return index, nil + } return index, filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { if d == nil { return fmt.Errorf("invalid path: %s", path) diff --git a/services/mopidy/mopidy.go b/services/mopidy/mopidy.go index 8ef50c1..cec1e41 100644 --- a/services/mopidy/mopidy.go +++ b/services/mopidy/mopidy.go @@ -74,8 +74,10 @@ func (c *Client) State() (*player.State, error) { query := artist + " " + current.Result.Name return &player.State{ - ID: current.Result.URI, - Query: query, + Track: player.TrackMetadata{ + ID: current.Result.URI, + Query: query, + }, Position: position.Result, Playing: state.Result == "playing", }, err diff --git a/services/mpd/mpd.go b/services/mpd/mpd.go index daa5dac..aa65daa 100644 --- a/services/mpd/mpd.go +++ b/services/mpd/mpd.go @@ -1,6 +1,7 @@ package mpd import ( + "net/url" "sptlrx/player" "strconv" @@ -73,9 +74,18 @@ func (c *Client) State() (*player.State, error) { query = title } + var uri *url.URL + u, err := url.Parse(current["file"]) + if err == nil && u.Path != "" { + uri = u + } + return &player.State{ - ID: status["songid"], - Query: query, + Track: player.TrackMetadata{ + ID: status["songid"], + Uri: uri, + Query: query, + }, Playing: status["state"] == "play", Position: int(elapsed) * 1000, }, nil diff --git a/services/mpris/mpris_unix.go b/services/mpris/mpris_unix.go index f34fb6b..082f6ae 100644 --- a/services/mpris/mpris_unix.go +++ b/services/mpris/mpris_unix.go @@ -81,11 +81,14 @@ func (c *Client) State() (*player.State, error) { title = t } + var uri *url.URL // In case the player uses the file name with extension as title if u, ok := meta["xesam:url"].Value().(string); ok { u, err := url.Parse(u) - if err == nil { + if err == nil && u.Path != "" { ext := filepath.Ext(u.Path) + uri = u + // some players use filename as title when tag is absent => trim extension from title title = strings.TrimSuffix(title, ext) } } @@ -106,8 +109,11 @@ func (c *Client) State() (*player.State, error) { } return &player.State{ - ID: query, // use query as id since mpris:trackid is broken - Query: query, + Track: player.TrackMetadata{ + ID: query, // use query as id since mpris:trackid is broken + Uri: uri, + Query: query, + }, Position: int(position * 1000), // secs to ms Playing: status == mpris.PlaybackPlaying, }, err diff --git a/services/spotify/spotify.go b/services/spotify/spotify.go index faf0221..63a7a58 100644 --- a/services/spotify/spotify.go +++ b/services/spotify/spotify.go @@ -33,21 +33,21 @@ func (c *Client) State() (*player.State, error) { } return &player.State{ - ID: "spotify:" + result.Item.ID, + Track: player.TrackMetadata{ID: "spotify:" + result.Item.ID}, Position: result.Progress, Playing: result.Playing, }, nil } -func (c *Client) Lyrics(id, query string) ([]lyrics.Line, error) { +func (c *Client) Lyrics(track *player.TrackMetadata) ([]lyrics.Line, error) { var ( result *lyricsapi.LyricsResult err error ) - if strings.HasPrefix(id, "spotify:") { - result, err = c.api.GetByID(id[8:]) + if strings.HasPrefix(track.ID, "spotify:") { + result, err = c.api.GetByID(track.ID[8:]) } else { - result, err = c.api.GetByName(query) + result, err = c.api.GetByName(track.Query) } if err != nil {