Skip to content

Commit bb7a990

Browse files
Merge pull request #3 from GerardPolloRebozado/feat/lastfm-integration
Feat/lastfm integration
2 parents b0462b4 + 17d0baa commit bb7a990

12 files changed

Lines changed: 314 additions & 59 deletions

File tree

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Navifetch
22

3-
This tool is in early stages of development. For now it has only been tested with Navidrome and Aonsoku
3+
This tool is in the early stages of development. For now, it has only been tested with Navidrome and Aonsoku
44
Navifetch is a specialized proxy for Subsonic-compatible music servers (such as Navidrome). It enhances your music streaming experience by acting as an intermediary that can fetch missing content on the fly.
55

66
### How It Works
@@ -13,15 +13,15 @@ If you choose to play a song found via iTunes, Navifetch downloads it using `yt-
1313

1414
### Disclaimer
1515

16-
This tool is intended for use with authorized content only. The developer is not responsible for copyright infringement or any damages resulting from the use of this software.
16+
This tool is intended for use with authorized content only. The developer is not responsible for copyright infringement or any damage resulting from the use of this software.
1717

1818
### Features
1919

2020
- Seamless integration with Subsonic-compatible clients.
21-
- Automatic fallback to iTunes/MusicBrainz API for missing content. (iTunes doesn't work very well)
21+
- Automatic fallback to LastFM/iTunes/MusicBrainz API for missing content. (iTunes doesn't work very well)
2222
- Dynamic downloading and streaming.
2323
- Automatic cleanup of temporary files.
24-
- Persistent storage for tracks added to playlists.
24+
- Persistent storage for tracks is added to playlists.
2525

2626
### Installation
2727

@@ -47,12 +47,13 @@ services:
4747
### Configuration
4848

4949
Navifetch can be configured using the following environment variables:
50-
It is recommended to use MusicBrainz for metadata as iTunes wont be able to stream directly when downloading a song it will only be able to downlaod it.
50+
It is recommended to use LastFM for metadata as it has a better search engine, also iTunes won't be able to stream directly when downloading a song it will only be able to download it and then search again to stream it.
5151

5252
| Variable | Description | Default |
5353
|---------------------|---------------------------------------------------------------|---------|
5454
| `NAVIDROME_BASE` | **Required**. The base URL of your Subsonic/Navidrome server. | None |
5555
| `COUNTRY` | The country code to use for iTunes API requests. | `US` |
56-
| `METADATA_PROVIDER` | The metadata provider to use for. `itunes` or `musicbrainz`. | None |
56+
| `METADATA_PROVIDER` | The metadata provider to use: `itunes`, `musicbrainz`, or `lastfm`. | None |
57+
| `LASTFM_API_KEY` | **Required for lastfm**. Your Last.fm API key. | None |
5758
| `RESULTS_PER_PAGE` | The number of results to display per page. | `10` |
5859

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,7 @@ require (
1111

1212
require (
1313
github.com/go-resty/resty/v2 v2.17.1 // indirect
14+
github.com/google/go-querystring v1.2.0 // indirect
15+
github.com/twoscott/gobble-fm v1.0.9 // indirect
1416
golang.org/x/net v0.48.0 // indirect
1517
)

go.sum

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
22
github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
3+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
4+
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
5+
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
36
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
47
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
58
github.com/torabit/itunes v0.0.0-20230702053550-80ae037e7f4f h1:xMCVryBmnnZP14qap4kDHrE7c7YmvhVDaDMCMMT7Pdw=
69
github.com/torabit/itunes v0.0.0-20230702053550-80ae037e7f4f/go.mod h1:Hm4MVfhzwIYmI8+6Az10Gh8VEEM+LTw2TfP8QZJ/G34=
10+
github.com/twoscott/gobble-fm v1.0.9 h1:FfEKNbalQo/FK5IV7Pn/Jp39Tku98uNsnyuQK6/eyoQ=
11+
github.com/twoscott/gobble-fm v1.0.9/go.mod h1:uiQfweUmQu8bxrlP+2e8wSeoMgV/OfV7FAZvOXewyUk=
712
go.uploadedlobster.com/mbtypes v0.4.0 h1:D5asCgHsRWufj4Yn5u0IuH2J9z1UuYImYkYIp1Z1Q7s=
813
go.uploadedlobster.com/mbtypes v0.4.0/go.mod h1:Bu1K1Hl77QTAE2Z7QKiW/JAp9KqYWQebkRRfG02dlZM=
914
go.uploadedlobster.com/musicbrainzws2 v0.18.0 h1:fNhAadkhq6L9x+p02xhU6yrQ6AJq892gt+LMkvwf9/w=

src/api/handler.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ type Handler struct {
2626
}
2727

2828
func NewHandler(cfg *config.Config, rp *service.SubsonicReverseProxy) *Handler {
29-
p, err := metadata.NewProvider(cfg.MetadataProvider, cfg.Country, cfg.Limit)
29+
p, err := metadata.NewProvider(cfg.MetadataProvider, cfg.Country, cfg.Limit, cfg.LastFMApiKey)
3030
if err != nil {
3131
log.Fatalf("Failed to initialize metadata provider: %v", err)
3232
}
@@ -41,7 +41,7 @@ func NewHandler(cfg *config.Config, rp *service.SubsonicReverseProxy) *Handler {
4141
}
4242
}
4343

44-
func (h *Handler) Healthz(w http.ResponseWriter, r *http.Request) {
44+
func (h *Handler) Healthz(w http.ResponseWriter, _ *http.Request) {
4545
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
4646
w.WriteHeader(http.StatusOK)
4747
_, _ = w.Write([]byte("ok"))
@@ -114,7 +114,13 @@ func (h *Handler) ProxyStream(w http.ResponseWriter, r *http.Request) {
114114

115115
title := strings.TrimSuffix(songMetadata.Title, " (external)")
116116
artist := songMetadata.Artist
117-
mbid := trackID
117+
mbid := songMetadata.MusicBrainzId
118+
if mbid == "" {
119+
mbid = trackID
120+
if strings.HasPrefix(mbid, "external-") {
121+
mbid = strings.TrimPrefix(mbid, "external-")
122+
}
123+
}
118124

119125
foundSong, err := h.rp.FindNavidromeSongID(artist, title, mbid, r)
120126
if err != nil {
@@ -152,7 +158,13 @@ func (h *Handler) ProxyPlaylist(w http.ResponseWriter, r *http.Request) {
152158

153159
title := strings.TrimSuffix(songMetadata.Title, " (external)")
154160
artist := songMetadata.Artist
155-
mbid := id
161+
mbid := songMetadata.MusicBrainzId
162+
if mbid == "" {
163+
mbid = id
164+
if strings.HasPrefix(mbid, "external-") {
165+
mbid = strings.TrimPrefix(mbid, "external-")
166+
}
167+
}
156168

157169
foundSong, err := h.rp.FindNavidromeSongID(artist, title, mbid, r)
158170
if err != nil {

src/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type Config struct {
1616
MetadataProvider string
1717
Country string
1818
Limit int
19+
LastFMApiKey string
1920
}
2021

2122
func LoadConfig() (*Config, error) {
@@ -44,6 +45,7 @@ func LoadConfig() (*Config, error) {
4445
MetadataProvider: getEnv("METADATA_PROVIDER", "itunes"),
4546
Country: getEnv("COUNTRY", "US"),
4647
Limit: limit,
48+
LastFMApiKey: getEnv("LASTFM_API_KEY", ""),
4749
}, nil
4850
}
4951

src/metadata/IMetadata.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type Provider interface {
1818

1919
var metadataProvider Provider
2020

21-
func NewProvider(name string, country string, limit int) (Provider, error) {
21+
func NewProvider(name string, country string, limit int, apiKey string) (Provider, error) {
2222
if metadataProvider != nil {
2323
return metadataProvider, nil
2424
}
@@ -29,14 +29,10 @@ func NewProvider(name string, country string, limit int) (Provider, error) {
2929
case "musicbrainz":
3030
metadataProvider = NewMusicBrainzProvider(limit)
3131
return metadataProvider, nil
32+
case "lastfm":
33+
metadataProvider = NewLastFMProvider(apiKey, limit)
34+
return metadataProvider, nil
3235
default:
3336
return nil, fmt.Errorf("unsupported metadata provider: %s", name)
3437
}
3538
}
36-
37-
func GetProvider() Provider {
38-
if metadataProvider == nil {
39-
panic("metadata provider not initialized")
40-
}
41-
return metadataProvider
42-
}

src/metadata/LastFM.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package metadata
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
"sync"
8+
"time"
9+
10+
"github.com/GerardPolloRebozado/navifetch/src/model"
11+
"github.com/GerardPolloRebozado/navifetch/src/util"
12+
"github.com/twoscott/gobble-fm/api"
13+
"github.com/twoscott/gobble-fm/lastfm"
14+
)
15+
16+
type LastFMProvider struct {
17+
client *api.Client
18+
limit int
19+
}
20+
21+
func NewLastFMProvider(apiKey string, limit int) *LastFMProvider {
22+
return &LastFMProvider{
23+
client: api.NewClientKeyOnly(apiKey),
24+
limit: limit,
25+
}
26+
}
27+
28+
func (p *LastFMProvider) SearchSongs(_ context.Context, query string) ([]model.SubsonicSong, error) {
29+
params := lastfm.TrackSearchParams{
30+
Track: query,
31+
Limit: uint(p.limit),
32+
}
33+
res, err := p.client.Track.Search(params)
34+
if err != nil {
35+
return nil, err
36+
}
37+
38+
songs := make([]model.SubsonicSong, 0)
39+
for _, t := range res.Tracks {
40+
songs = append(songs, p.toSubsonicSong(t.Title, t.Artist, t.MBID, "", "", 0))
41+
}
42+
return songs, nil
43+
}
44+
45+
func (p *LastFMProvider) SearchAlbums(_ context.Context, query string) ([]model.SubsonicAlbum, error) {
46+
params := lastfm.AlbumSearchParams{
47+
Album: query,
48+
Limit: uint(p.limit),
49+
}
50+
res, err := p.client.Album.Search(params)
51+
if err != nil {
52+
return nil, err
53+
}
54+
55+
albums := make([]model.SubsonicAlbum, 0)
56+
for _, a := range res.Albums {
57+
mbid := a.MBID
58+
if mbid == "" && a.Artist != "" && a.Title != "" {
59+
info, err := p.client.Album.Info(lastfm.AlbumInfoParams{
60+
Artist: a.Artist,
61+
Album: a.Title,
62+
})
63+
if err == nil && info != nil {
64+
mbid = info.MBID
65+
}
66+
}
67+
68+
if mbid != "" {
69+
albums = append(albums, p.toSubsonicAlbum(a.Title, a.Artist, mbid, 0))
70+
}
71+
}
72+
return albums, nil
73+
}
74+
75+
func (p *LastFMProvider) GetAlbumSongs(_ context.Context, albumID string) ([]model.SubsonicSong, error) {
76+
albumID = strings.TrimPrefix(albumID, "external-")
77+
albumRes, err := p.client.Album.InfoByMBID(lastfm.AlbumInfoMBIDParams{MBID: albumID})
78+
if err != nil {
79+
return nil, err
80+
}
81+
82+
songs := make([]model.SubsonicSong, len(albumRes.Tracks))
83+
var wg sync.WaitGroup
84+
for i, t := range albumRes.Tracks {
85+
wg.Add(1)
86+
go func(idx int, trackTitle, trackArtist string) {
87+
defer wg.Done()
88+
trackRes, err := p.client.Track.Info(lastfm.TrackInfoParams{
89+
Artist: trackArtist,
90+
Track: trackTitle,
91+
})
92+
if err == nil && trackRes != nil {
93+
songs[idx] = p.toSubsonicSong(trackRes.Title, trackRes.Artist.Name, trackRes.MBID, trackRes.Album.Title, trackRes.Album.MBID, int64(trackRes.Duration.Unwrap().Seconds()))
94+
} else {
95+
songs[idx] = p.toSubsonicSong(trackTitle, trackArtist, "", albumRes.Title, albumRes.MBID, 0)
96+
}
97+
}(i, t.Title, t.Artist.Name)
98+
}
99+
wg.Wait()
100+
101+
return songs, nil
102+
}
103+
104+
func (p *LastFMProvider) GetSong(_ context.Context, id string) (*model.SubsonicSong, error) {
105+
id = strings.TrimPrefix(id, "external-")
106+
res, err := p.client.Track.InfoByMBID(lastfm.TrackInfoMBIDParams{MBID: id})
107+
if err != nil {
108+
return nil, err
109+
}
110+
111+
song := p.toSubsonicSong(res.Title, res.Artist.Name, res.MBID, res.Album.Title, res.Album.MBID, int64(res.Duration.Unwrap().Seconds()))
112+
return &song, nil
113+
}
114+
115+
func (p *LastFMProvider) GetAlbum(_ context.Context, id string) (*model.SubsonicAlbum, error) {
116+
id = strings.TrimPrefix(id, "external-")
117+
res, err := p.client.Album.InfoByMBID(lastfm.AlbumInfoMBIDParams{MBID: id})
118+
if err != nil {
119+
return nil, err
120+
}
121+
122+
a := p.toSubsonicAlbum(res.Title, res.Artist, res.MBID, int64(len(res.Tracks)))
123+
return &a, nil
124+
}
125+
126+
func (p *LastFMProvider) GetCoverArt(ctx context.Context, id string, _ int64) ([]byte, string, error) {
127+
id = strings.TrimPrefix(id, "external-")
128+
var imageURL string
129+
tInfo, err := p.client.Track.InfoByMBID(lastfm.TrackInfoMBIDParams{MBID: id})
130+
if err == nil && tInfo != nil && tInfo.Album.Image.OriginalURL() != "" {
131+
imageURL = tInfo.Album.Image.OriginalURL()
132+
} else {
133+
aInfo, err := p.client.Album.InfoByMBID(lastfm.AlbumInfoMBIDParams{MBID: id})
134+
if err == nil && aInfo != nil && aInfo.Image.OriginalURL() != "" {
135+
imageURL = aInfo.Image.OriginalURL()
136+
}
137+
}
138+
139+
if imageURL == "" {
140+
return nil, "", fmt.Errorf("no cover art found for ID: %s", id)
141+
}
142+
143+
body, _, contentType, err := util.HTTPGet(ctx, imageURL, nil)
144+
return body, contentType, err
145+
}
146+
147+
func (p *LastFMProvider) toSubsonicSong(title, artist, mbid, album, albumMBID string, duration int64) model.SubsonicSong {
148+
coverArtID := albumMBID
149+
if coverArtID == "" {
150+
coverArtID = mbid
151+
}
152+
153+
return model.SubsonicSong{
154+
ID: "external-" + mbid,
155+
Title: title + " (external)",
156+
Artist: artist,
157+
DisplayArtist: artist,
158+
Album: album,
159+
AlbumID: "external-" + albumMBID,
160+
CoverArt: "external-" + coverArtID,
161+
Duration: duration,
162+
IsDir: false,
163+
ContentType: "audio/mpeg",
164+
Suffix: "mp3",
165+
TranscodedSuffix: "mp3",
166+
TranscodedContentType: "audio/mpeg",
167+
Type: "music",
168+
MediaType: "song",
169+
Created: time.Now(),
170+
MusicBrainzId: mbid,
171+
}
172+
}
173+
174+
func (p *LastFMProvider) toSubsonicAlbum(title, artist, mbid string, songCount int64) model.SubsonicAlbum {
175+
return model.SubsonicAlbum{
176+
ID: "external-" + mbid,
177+
Album: title,
178+
Title: title,
179+
Name: title,
180+
Artist: artist,
181+
CoverArt: "external-" + mbid,
182+
SongCount: songCount,
183+
IsDir: true,
184+
Created: time.Now(),
185+
}
186+
}

src/model/model.go

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,22 +42,23 @@ type SubsonicAlbumResponse struct {
4242
}
4343

4444
type SubsonicAlbum struct {
45-
ID string `json:"id"`
46-
Parent string `json:"parent"`
47-
Album string `json:"album"`
48-
Title string `json:"title"`
49-
Name string `json:"name"`
50-
IsDir bool `json:"isDir"`
51-
CoverArt string `json:"coverArt"`
52-
SongCount int64 `json:"songCount"`
53-
Created time.Time `json:"created"`
54-
Duration int `json:"duration"`
55-
PlayCount int `json:"playCount"`
56-
ArtistID string `json:"artistId"`
57-
Artist string `json:"artist"`
58-
Year int `json:"year"`
59-
Genre string `json:"genre"`
60-
Song []SubsonicSong `json:"song"`
45+
ID string `json:"id"`
46+
Parent string `json:"parent"`
47+
Album string `json:"album"`
48+
Title string `json:"title"`
49+
Name string `json:"name"`
50+
IsDir bool `json:"isDir"`
51+
CoverArt string `json:"coverArt"`
52+
SongCount int64 `json:"songCount"`
53+
Created time.Time `json:"created"`
54+
Duration int `json:"duration"`
55+
PlayCount int `json:"playCount"`
56+
ArtistID string `json:"artistId"`
57+
Artist string `json:"artist"`
58+
Year int `json:"year"`
59+
Genre string `json:"genre"`
60+
MusicBrainzId string `json:"musicBrainzId"`
61+
Song []SubsonicSong `json:"song"`
6162
}
6263

6364
type SearchResult3 struct {

0 commit comments

Comments
 (0)