Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for local lyric uri #53

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
```
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type Config struct {
} `yaml:"browser"`

Local struct {
Enabled bool `default: "false" yaml:"enabled"`
Folder string `yaml:"folder"`
} `yaml:"local"`
}
Expand Down
4 changes: 3 additions & 1 deletion lyrics/lyrics.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion player/player.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
9 changes: 4 additions & 5 deletions pool/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 4 additions & 2 deletions services/browser/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions services/hosted/hosted.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"sptlrx/lyrics"
"sptlrx/player"
)

// Host your own: https://github.com/raitonoberu/lyricsapi
Expand All @@ -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)
Expand Down
75 changes: 62 additions & 13 deletions services/local/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"fmt"
"io"
"io/fs"
"net/url"
"os"
"path/filepath"
"sptlrx/lyrics"
"sptlrx/player"
"strconv"
"strings"
)
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions services/mopidy/mopidy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions services/mpd/mpd.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package mpd

import (
"net/url"
"sptlrx/player"
"strconv"

Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions services/mpris/mpris_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions services/spotify/spotify.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down