Compare commits

...

12 Commits

Author SHA1 Message Date
Deluan
a6a3ef6ea5 test(e2e): tests are fast, no need to skip on -short
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 22:34:08 -05:00
Deluan
f67324278e test(e2e): add tests for multi-library support and user access control
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 22:34:08 -05:00
Deluan
fc8b7283f0 test(e2e): add tests for album sharing and user isolation scenarios
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 22:34:08 -05:00
Deluan
777638e84d fix(e2e): improve database handling and snapshot restoration in tests
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 22:34:08 -05:00
Deluan
ebe3c1d06c test(e2e): add comprehensive tests for Subsonic API endpoints
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 22:34:08 -05:00
Deluan
408aa78ed5 fix(scanner): log warning when metadata extraction fails
Added a warning log when the gotaglib extractor fails to read metadata
from a file. Previously, extraction errors were silently skipped, making
it difficult to diagnose issues with unreadable files during scanning.

Ref: https://github.com/navidrome/navidrome/issues/4604#issuecomment-3865690165
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 21:39:07 -05:00
Deluan
29f98b889b chore(deps): update dependencies in go.mod and go.sum to latest versions
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 13:23:58 -05:00
Kendall Garner
1e37e680d7 feat(agents): Add artist url and top and similar songs to ListenBrainz agent (#4934)
* feat(agents): Add artist url and top songs to ListenBrainz agent

* add newline at end of file

* respond to some feedback

* add more tests, include more metadata in top songs

* add duration to album info

* add similar artists from labs

* add similar artists and track radio

* fix(client): replace sort with slices.SortFunc for deterministic ordering of recordings with same score

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: typos

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: use struct literal initialization consistently

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: configurable artist and track algorithms

Signed-off-by: Deluan <deluan@navidrome.org>

* test configuration changes

---------

Signed-off-by: Deluan <deluan@navidrome.org>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 13:20:42 -05:00
Kendall Garner
6fb4cd277e feat(subsonic): add OS readonly and validUntil properties in playlists (#4993)
* feat(subsonic): add OS readonly and validUntil properties

* remove duplicated test

* test: fix and enable disabled child smart playlist tests

Fixed the XContext("child smart playlists") tests that were disabled with
a TODO comment. The tests had several issues: nested playlists were missing
Public: true (required by InPlaylist criteria), the criteria matched no
test fixtures, the "not expired" test set EvaluatedAt on the parent too
(preventing it from refreshing at all), and the "expired" test dereferenced
a nil EvaluatedAt. Added proper cleanup with DeferCleanup and config
restoration via configtest.

* fix(subsonic): always include readonly field in JSON playlist responses

Removed omitempty from the JSON tag of the Readonly field in
OpenSubsonicPlaylist so that readonly: false is always serialized in
JSON responses, per the OpenSubsonic spec requirement that supported
fields must be returned with default values. Added a test case with an
empty OpenSubsonicPlaylist to verify the behavior.

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-02-06 19:35:54 -05:00
Deluan
e11206f0ee fix(lastfm): clean up Last.fm content by removing "Read more" links from descriptions and bios
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-06 16:52:34 -05:00
Deluan Quintão
b4e03673ba fix(scanner): preserve parentheses in lyrics when processing alias tags (#4985)
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-06 16:21:35 -05:00
Deluan
01c839d9be fix: add music.old to .dockerignore and .gitignore 2026-02-06 07:40:05 -05:00
44 changed files with 4202 additions and 135 deletions

View File

@@ -15,4 +15,5 @@ dist
binaries
cache
music
music.old
!Dockerfile

1
.gitignore vendored
View File

@@ -20,6 +20,7 @@ cache/*
coverage.out
dist
music
music.old
*.db*
.gitinfo
docker-compose.yml

View File

@@ -49,6 +49,7 @@ func (e extractor) Version() string {
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
f, close, err := e.openFile(filePath)
if err != nil {
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
return nil, err
}
defer close()

View File

@@ -31,6 +31,12 @@ var ignoredContent = []string{
`<a href="https://www.last.fm/music/`,
}
var lastFMReadMoreRegex = regexp.MustCompile(`\s*<a href="https://www\.last\.fm/music/[^"]*">Read more on Last\.fm</a>\.?`)
func cleanContent(content string) string {
return strings.TrimSpace(lastFMReadMoreRegex.ReplaceAllString(content, ""))
}
type lastfmAgent struct {
ds model.DataStore
sessionKeys *agents.SessionKeys
@@ -95,7 +101,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
resp.MBID = a.MBID
resp.URL = a.URL
if isValidContent(a.Description.Summary) {
resp.Description = strings.TrimSpace(a.Description.Summary)
resp.Description = cleanContent(a.Description.Summary)
return &resp, nil
}
log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang)
@@ -171,7 +177,7 @@ func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid str
return "", err
}
if isValidContent(a.Bio.Summary) {
return strings.TrimSpace(a.Bio.Summary), nil
return cleanContent(a.Bio.Summary), nil
}
log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
}

View File

@@ -80,7 +80,7 @@ var _ = Describe("lastfmAgent", func() {
It("returns the biography", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. <a href=\"https://www.last.fm/music/U2\">Read more on Last.fm</a>"))
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente."))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
@@ -535,7 +535,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
Name: "Believe",
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob <a href=\"https://www.last.fm/music/Cher/Believe\">Read more on Last.fm</a>.",
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob",
URL: "https://www.last.fm/music/Cher/Believe",
}))
Expect(httpClient.RequestCount).To(Equal(1))

View File

@@ -118,12 +118,129 @@ func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) boo
return err == nil && sk != ""
}
func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
if mbid == "" {
return "", agents.ErrNotFound
}
url, err := l.client.getArtistUrl(ctx, mbid)
if err != nil {
return "", err
}
return url, nil
}
func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
resp, err := l.client.getArtistTopSongs(ctx, mbid, count)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
res := make([]agents.Song, len(resp))
for i, t := range resp {
mbid := ""
if len(t.ArtistMBIDs) > 0 {
mbid = t.ArtistMBIDs[0]
}
res[i] = agents.Song{
Album: t.ReleaseName,
AlbumMBID: t.ReleaseMBID,
Artist: t.ArtistName,
ArtistMBID: mbid,
Duration: t.DurationMs,
Name: t.RecordingName,
MBID: t.RecordingMbid,
}
}
return res, nil
}
func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
if mbid == "" {
return nil, agents.ErrNotFound
}
resp, err := l.client.getSimilarArtists(ctx, mbid, limit)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
artists := make([]agents.Artist, len(resp))
for i, artist := range resp {
artists[i] = agents.Artist{
MBID: artist.MBID,
Name: artist.Name,
}
}
return artists, nil
}
func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) {
if mbid == "" {
return nil, agents.ErrNotFound
}
resp, err := l.client.getSimilarRecordings(ctx, mbid, limit)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
songs := make([]agents.Song, len(resp))
for i, song := range resp {
songs[i] = agents.Song{
Album: song.ReleaseName,
AlbumMBID: song.ReleaseMBID,
Artist: song.Artist,
MBID: song.MBID,
Name: song.Name,
}
}
return songs, nil
}
func init() {
conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled {
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
return listenBrainzConstructor(ds)
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := listenBrainzConstructor(ds)
if a != nil {
return a
}
return nil
})
agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface {
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := listenBrainzConstructor(ds)
if a != nil {
return a
}
return nil
})
}
})
}
var (
_ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil)
_ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil)
_ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil)
_ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil)
)

View File

@@ -4,11 +4,14 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
@@ -162,4 +165,279 @@ var _ = Describe("listenBrainzAgent", func() {
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
})
Describe("GetArtistUrl", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns artist url when MBID present", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/"))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
})
It("returns error when url not present", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
It("returns error when fetch calls fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
It("returns error when ListenBrainz returns an error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
StatusCode: 400,
}
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
})
Describe("GetTopSongs", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
StatusCode: 400,
}
_, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1"))
})
It("returns all tracks when asked", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
{
ID: "",
Name: "String Theocracy",
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "String Theocracy",
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
Duration: 174000,
},
}))
})
It("returns only one track when prompted", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artist: "Mili",
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
}))
})
})
Describe("GetSimilarArtists", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids="
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := agent.GetSimilarArtists(ctx, "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
})
It("returns all data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"},
}))
})
It("returns subset of data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
}))
})
})
Describe("GetSimilarTracks", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids="
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
})
It("returns all data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artist: "aha",
ArtistMBID: "",
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
{
ID: "",
Name: "Wake Me Up Before You GoGo",
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
ISRC: "",
Artist: "Wham!",
ArtistMBID: "",
Album: "Make It Big",
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
Duration: 0,
},
}))
})
It("returns subset of data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artist: "aha",
ArtistMBID: "",
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
}))
})
})
})

View File

@@ -2,16 +2,29 @@ package listenbrainz
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"slices"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
)
const (
lbzApiUrl = "https://api.listenbrainz.org/1/"
labsBase = "https://labs.api.listenbrainz.org/"
)
var (
ErrorNotFound = errors.New("listenbrainz: not found")
)
type listenBrainzError struct {
Code int
Message string
@@ -88,7 +101,7 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain
r := &listenBrainzRequest{
ApiKey: apiKey,
}
response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r)
response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r)
if err != nil {
return nil, err
}
@@ -104,7 +117,7 @@ func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenI
},
}
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
@@ -122,7 +135,7 @@ func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) err
Payload: []listenInfo{li},
},
}
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
@@ -141,7 +154,7 @@ func (c *client) path(endpoint string) (string, error) {
return u.String(), nil
}
func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
b, _ := json.Marshal(r.Body)
uri, err := c.path(endpoint)
if err != nil {
@@ -177,3 +190,189 @@ func (c *client) makeRequest(ctx context.Context, method string, endpoint string
return &response, nil
}
type lbzHttpError struct {
Code int `json:"code"`
Error string `json:"error"`
}
func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) {
req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = params.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
// On a 200 code, there is no code. Decode using using error message if it exists
if resp.StatusCode != 200 {
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var lbzError lbzHttpError
jsonErr := decoder.Decode(&lbzError)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error}
}
return resp, err
}
type artistMetadataResult struct {
Rels struct {
OfficialHomepage string `json:"official homepage,omitempty"`
} `json:"rels,omitzero"`
}
func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) {
params := url.Values{}
params.Add("artist_mbids", mbid)
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params)
if err != nil {
return "", err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response []artistMetadataResult
jsonErr := decoder.Decode(&response)
if jsonErr != nil {
return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(response) == 0 || response[0].Rels.OfficialHomepage == "" {
return "", ErrorNotFound
}
return response[0].Rels.OfficialHomepage, nil
}
type trackInfo struct {
ArtistName string `json:"artist_name"`
ArtistMBIDs []string `json:"artist_mbids"`
DurationMs uint32 `json:"length"`
RecordingName string `json:"recording_name"`
RecordingMbid string `json:"recording_mbid"`
ReleaseName string `json:"release_name"`
ReleaseMBID string `json:"release_mbid"`
}
func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) {
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{})
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response []trackInfo
jsonErr := decoder.Decode(&response)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(response) > count {
return response[0:count], nil
}
return response, nil
}
type artist struct {
MBID string `json:"artist_mbid"`
Name string `json:"name"`
Score int `json:"score"`
}
func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = url.Values{
"artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm},
}.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var artists []artist
jsonErr := decoder.Decode(&artists)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(artists) > limit {
return artists[:limit], nil
}
return artists, nil
}
type recording struct {
MBID string `json:"recording_mbid"`
Name string `json:"recording_name"`
Artist string `json:"artist_credit_name"`
ReleaseName string `json:"release_name"`
ReleaseMBID string `json:"release_mbid"`
Score int `json:"score"`
}
func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = url.Values{
"recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm},
}.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var recordings []recording
jsonErr := decoder.Decode(&recordings)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
// For whatever reason, labs API isn't guaranteed to give results in the proper order
// and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json
// generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30
slices.SortFunc(recordings, func(a, b recording) int {
return cmp.Or(
cmp.Compare(b.Score, a.Score), // Sort by score descending
cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates
)
})
recordings = slices.CompactFunc(recordings, func(a, b recording) bool {
return a.MBID == b.MBID
})
if len(recordings) > limit {
return recordings[:limit], nil
}
return recordings, nil
}

View File

@@ -4,10 +4,13 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -117,4 +120,345 @@ var _ = Describe("client", func() {
})
})
})
Context("getArtistUrl", func() {
baseUrl := "https://api.listenbrainz.org/1/metadata/artist?"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
StatusCode: 400,
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid."))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles a malformed request without meaningful body", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(``)),
StatusCode: 501,
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns not found when the artist has no official homepage", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err.Error()).To(Equal("listenbrainz: not found"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns data when the artist has a homepage", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")
Expect(err).ToNot(HaveOccurred())
Expect(url).To(Equal("http://projectmili.com/"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
})
Context("getArtistTopSongs", func() {
baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
StatusCode: 400,
}
_, err := client.getArtistTopSongs(context.Background(), "1", 50)
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles a malformed request without standard body", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(``)),
StatusCode: 500,
}
_, err := client.getArtistTopSongs(context.Background(), "1", 1)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns all tracks when given the opportunity", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]trackInfo{
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 211912,
RecordingName: "world.execute(me);",
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
ReleaseName: "Miracle Milk",
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
},
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 174000,
RecordingName: "String Theocracy",
RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
ReleaseName: "String Theocracy",
ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
},
}))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns a subset of tracks when allowed", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]trackInfo{
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 211912,
RecordingName: "world.execute(me);",
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
ReleaseName: "Miracle Milk",
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
},
}))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
})
Context("getSimilarArtists", func() {
var algorithm string
BeforeEach(func() {
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DeferCleanup(configtest.SetupConfig())
})
getUrl := func(mbid string) string {
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid)
}
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := client.getSimilarArtists(context.Background(), "1", 2)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles real data properly", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
}))
})
It("truncates data when requested", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
}))
})
It("fetches a different endpoint when algorithm changes", func() {
algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
conf.Server.ListenBrainz.ArtistAlgorithm = algorithm
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
}))
})
})
Context("getSimilarRecordings", func() {
var algorithm string
BeforeEach(func() {
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DeferCleanup(configtest.SetupConfig())
})
getUrl := func(mbid string) string {
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid)
}
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := client.getSimilarRecordings(context.Background(), "1", 2)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles real data properly", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
{
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
Name: "Wake Me Up Before You GoGo",
Artist: "Wham!",
ReleaseName: "Make It Big",
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
Score: 65,
},
}))
})
It("truncates data when requested", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
}))
})
It("properly sorts by score and truncates duplicates", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
// There are actually 5 items. The dedup should happen FIRST
resp, err := client.getSimilarRecordings(context.Background(), mbid, 4)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
{
MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5",
Name: "Everybody Wants to Rule the World",
Artist: "Tears for Fears",
ReleaseName: "Songs From the Big Chair",
ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597",
Score: 68,
},
{
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
Name: "Wake Me Up Before You GoGo",
Artist: "Wham!",
ReleaseName: "Make It Big",
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
Score: 65,
},
{
MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f",
Name: "Tainted Love",
Artist: "Soft Cell",
ReleaseName: "Non-Stop Erotic Cabaret",
ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1",
Score: 61,
},
}))
})
It("uses a different algorithm when configured", func() {
algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
conf.Server.ListenBrainz.TrackAlgorithm = algorithm
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "aha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
}))
})
})
})

View File

@@ -194,8 +194,10 @@ type deezerOptions struct {
}
type listenBrainzOptions struct {
Enabled bool
BaseURL string
Enabled bool
BaseURL string
ArtistAlgorithm string
TrackAlgorithm string
}
type httpHeaderOptions struct {
@@ -656,7 +658,9 @@ func setViperDefaults() {
viper.SetDefault("deezer.enabled", true)
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpheaders.frameoptions", "DENY")
viper.SetDefault("backup.path", "")

View File

@@ -74,6 +74,10 @@ const (
DefaultHttpClientTimeOut = 10 * time.Second
DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/"
DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DefaultListenBrainzTrackAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DefaultScannerExtractor = "taglib"
DefaultWatcherWait = 5 * time.Second
Zwsp = string('\u200b')

12
go.mod
View File

@@ -14,7 +14,7 @@ require (
github.com/Masterminds/squirrel v1.5.4
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
github.com/andybalholm/cascadia v1.3.3
github.com/bmatcuk/doublestar/v4 v4.9.2
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
@@ -28,7 +28,7 @@ require (
github.com/dustin/go-humanize v1.0.1
github.com/extism/go-sdk v1.7.1
github.com/fatih/structs v1.1.0
github.com/go-chi/chi/v5 v5.2.4
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0
github.com/go-chi/jwtauth/v5 v5.3.3
@@ -49,8 +49,8 @@ require (
github.com/mattn/go-sqlite3 v1.14.33
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/onsi/ginkgo/v2 v2.27.5
github.com/onsi/gomega v1.39.0
github.com/onsi/ginkgo/v2 v2.28.1
github.com/onsi/gomega v1.39.1
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
github.com/pressly/goose/v3 v3.26.0
@@ -98,7 +98,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -134,7 +134,7 @@ require (
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect

24
go.sum
View File

@@ -16,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI=
github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
@@ -77,8 +77,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
@@ -110,8 +110,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -197,10 +197,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -301,8 +301,8 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=

View File

@@ -250,7 +250,15 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model
id3Base := parseID3Pairs(name, lowered)
if len(aliasValues) > 0 {
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
// For lyrics, don't use parseVorbisPairs as parentheses in lyrics content
// should not be interpreted as language keys (e.g. "(intro)" is not a language)
if name == model.TagLyrics {
for _, v := range aliasValues {
id3Base = append(id3Base, NewPair("xxx", v))
}
} else {
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
}
}
return id3Base
}

View File

@@ -246,6 +246,18 @@ var _ = Describe("Metadata", func() {
metadata.NewPair("eng", "Lyrics"),
))
})
It("should preserve lyrics starting with parentheses from alias tags", func() {
props.Tags = model.RawTags{
"LYRICS": {"(line one)\nline two\nline three"},
}
md = metadata.New(filePath, props)
Expect(md.All()).To(HaveKey(model.TagLyrics))
Expect(md.Strings(model.TagLyrics)).To(ContainElements(
metadata.NewPair("xxx", "(line one)\nline two\nline three"),
))
})
})
Describe("ReplayGain", func() {

View File

@@ -285,13 +285,16 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
}
// Update when the playlist was last refreshed (for cache purposes)
updSql := Update(r.tableName).Set("evaluated_at", time.Now()).Where(Eq{"id": pls.ID})
now := time.Now()
updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
_, err = r.executeSQL(updSql)
if err != nil {
log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
return false
}
pls.EvaluatedAt = &now
log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
return true

View File

@@ -4,6 +4,7 @@ import (
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
@@ -160,14 +161,23 @@ var _ = Describe("PlaylistRepository", func() {
})
})
// TODO Validate these tests
XContext("child smart playlists", func() {
When("refresh day has expired", func() {
Context("child smart playlists", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
When("refresh delay has expired", func() {
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
@@ -175,45 +185,69 @@ var _ = Describe("PlaylistRepository", func() {
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
// Nested playlist has not been evaluated yet
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(nestedPlsRead.EvaluatedAt).To(BeNil())
_, err = repo.GetWithTracks(parentPls.ID, true, false)
// Getting parent with refresh should recursively refresh the nested playlist
pls, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
Expect(pls.EvaluatedAt).ToNot(BeNil())
Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get
// Parent should have tracks from the nested playlist
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
// Nested playlist should now have been refreshed (EvaluatedAt set)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally(">", *nestedPlsRead.EvaluatedAt))
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
})
})
When("refresh day has not expired", func() {
When("refresh delay has not expired", func() {
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
childEvaluatedAt := time.Now().Add(-30 * time.Minute)
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
childRules := &criteria.Criteria{
Expression: criteria.All{
criteria.Contains{"title": "Day"},
},
}
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt}
Expect(repo.Put(&nestedPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
// Parent has no EvaluatedAt, so it WILL refresh, but the child should not
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
Expression: criteria.All{
criteria.InPlaylist{"id": nestedPls.ID},
},
}}
Expect(repo.Put(&parentPls)).To(Succeed())
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
nestedPlsRead, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
_, err = repo.GetWithTracks(parentPls.ID, true, false)
// Getting parent with refresh should NOT recursively refresh the nested playlist
parent, err := repo.GetWithTracks(parentPls.ID, true, false)
Expect(err).ToNot(HaveOccurred())
// Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get
// Parent should have been refreshed (its EvaluatedAt was nil)
Expect(parent.EvaluatedAt).ToNot(BeNil())
Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
// Nested playlist should NOT have been refreshed (still within delay window)
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
Expect(err).ToNot(HaveOccurred())
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second))
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
})
})

View File

@@ -0,0 +1,354 @@
package e2e
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"testing/fstest"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestSubsonicE2E(t *testing.T) {
tests.Init(t, false)
defer db.Close(t.Context())
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Subsonic API E2E Suite")
}
// Easy aliases for the storagetest package
type _t = map[string]any
var template = storagetest.Template
var track = storagetest.Track
// Shared test state
var (
ctx context.Context
ds *tests.MockDataStore
router *subsonic.Router
lib model.Library
// Snapshot paths for fast DB restore
dbFilePath string
snapshotPath string
// Admin user used for most tests
adminUser = model.User{
ID: "admin-1",
UserName: "admin",
Name: "Admin User",
IsAdmin: true,
}
)
func createFS(files fstest.MapFS) storagetest.FakeFS {
fs := storagetest.FakeFS{}
fs.SetFiles(files)
storagetest.Register("fake", &fs)
return fs
}
// buildTestFS creates the full test filesystem matching the plan
func buildTestFS() storagetest.FakeFS {
abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"})
help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"})
ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"})
kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"})
popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"})
return createFS(fstest.MapFS{
// Rock / The Beatles / Abbey Road
"Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together")),
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something")),
// Rock / The Beatles / Help!
"Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")),
// Rock / Led Zeppelin / IV
"Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")),
// Jazz / Miles Davis / Kind of Blue
"Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")),
// Pop (standalone track)
"Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")),
// _empty folder (directory with no audio)
"_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()},
})
}
// newReq creates an authenticated GET request for the given endpoint with optional query parameters.
// Parameters are provided as key-value pairs: newReq("getAlbum", "id", "123")
func newReq(endpoint string, params ...string) *http.Request {
return newReqWithUser(adminUser, endpoint, params...)
}
// newReqWithUser creates an authenticated GET request for the given user.
func newReqWithUser(user model.User, endpoint string, params ...string) *http.Request {
u := "/rest/" + endpoint
if len(params) > 0 {
q := url.Values{}
for i := 0; i < len(params)-1; i += 2 {
q.Add(params[i], params[i+1])
}
u += "?" + q.Encode()
}
r := httptest.NewRequest("GET", u, nil)
userCtx := request.WithUser(r.Context(), user)
userCtx = request.WithUsername(userCtx, user.UserName)
userCtx = request.WithClient(userCtx, "test-client")
userCtx = request.WithPlayer(userCtx, model.Player{ID: "player-1", Name: "Test Player", Client: "test-client"})
return r.WithContext(userCtx)
}
// newRawReq creates a ResponseRecorder + authenticated request for raw handlers (stream, download, getCoverArt).
func newRawReq(endpoint string, params ...string) (*httptest.ResponseRecorder, *http.Request) {
return httptest.NewRecorder(), newReq(endpoint, params...)
}
// newRawReqWithUser creates a ResponseRecorder + authenticated request for the given user.
func newRawReqWithUser(user model.User, endpoint string, params ...string) (*httptest.ResponseRecorder, *http.Request) {
return httptest.NewRecorder(), newReqWithUser(user, endpoint, params...)
}
// --- Noop stub implementations for Router dependencies ---
// noopArtwork implements artwork.Artwork
type noopArtwork struct{}
func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) {
return nil, time.Time{}, model.ErrNotFound
}
func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (io.ReadCloser, time.Time, error) {
return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil
}
// noopStreamer implements core.MediaStreamer
type noopStreamer struct{}
func (n noopStreamer) NewStream(context.Context, string, string, int, int) (*core.Stream, error) {
return nil, model.ErrNotFound
}
func (n noopStreamer) DoStream(context.Context, *model.MediaFile, string, int, int) (*core.Stream, error) {
return nil, model.ErrNotFound
}
// noopArchiver implements core.Archiver
type noopArchiver struct{}
func (n noopArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error {
return model.ErrNotFound
}
func (n noopArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error {
return model.ErrNotFound
}
func (n noopArchiver) ZipShare(context.Context, string, io.Writer) error {
return model.ErrNotFound
}
func (n noopArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error {
return model.ErrNotFound
}
// noopProvider implements external.Provider
type noopProvider struct{}
func (n noopProvider) UpdateAlbumInfo(_ context.Context, _ string) (*model.Album, error) {
return &model.Album{}, nil
}
func (n noopProvider) UpdateArtistInfo(_ context.Context, _ string, _ int, _ bool) (*model.Artist, error) {
return &model.Artist{}, nil
}
func (n noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) {
return nil, nil
}
func (n noopProvider) ArtistImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) {
return nil, model.ErrNotFound
}
// noopPlayTracker implements scrobbler.PlayTracker
type noopPlayTracker struct{}
func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error {
return nil
}
func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) {
return nil, nil
}
func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error {
return nil
}
// Compile-time interface checks
var (
_ artwork.Artwork = noopArtwork{}
_ core.MediaStreamer = noopStreamer{}
_ core.Archiver = noopArchiver{}
_ external.Provider = noopProvider{}
_ scrobbler.PlayTracker = noopPlayTracker{}
)
var _ = BeforeSuite(func() {
ctx = request.WithUser(GinkgoT().Context(), adminUser)
tmpDir := GinkgoT().TempDir()
dbFilePath = filepath.Join(tmpDir, "test-e2e.db")
snapshotPath = filepath.Join(tmpDir, "test-e2e.db.snapshot")
conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL"
db.Db().SetMaxOpenConns(1)
// Initial setup: schema, user, library, and full scan (runs once for the entire suite)
conf.Server.MusicFolder = "fake:///music"
conf.Server.DevExternalScanner = false
db.Init(ctx)
initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())}
auth.Init(initDS)
adminUserWithPass := adminUser
adminUserWithPass.NewPassword = "password"
Expect(initDS.User(ctx).Put(&adminUserWithPass)).To(Succeed())
lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"}
Expect(initDS.Library(ctx).Put(&lib)).To(Succeed())
Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName)
Expect(err).ToNot(HaveOccurred())
adminUser.Libraries = loadedUser.Libraries
ctx = request.WithUser(GinkgoT().Context(), adminUser)
buildTestFS()
s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(initDS), metrics.NewNoopInstance())
_, err = s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
// Checkpoint WAL and snapshot the golden DB state
_, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)")
Expect(err).ToNot(HaveOccurred())
data, err := os.ReadFile(dbFilePath)
Expect(err).ToNot(HaveOccurred())
Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed())
})
// setupTestDB restores the database from the golden snapshot and creates the
// Subsonic Router. Call this from BeforeEach/BeforeAll in each test container.
func setupTestDB() {
ctx = request.WithUser(GinkgoT().Context(), adminUser)
DeferCleanup(configtest.SetupConfig())
conf.Server.MusicFolder = "fake:///music"
conf.Server.DevExternalScanner = false
// Restore DB to golden state (no scan needed)
restoreDB()
ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
auth.Init(ds)
// Pre-populate repository cache with a valid context. The MockDataStore caches
// repositories on first access; without this, the first access may happen inside
// an errgroup (e.g., searchAll) whose context is canceled after Wait(), causing
// subsequent calls to silently fail.
ds.MediaFile(ctx)
ds.Album(ctx)
ds.Artist(ctx)
// Create the Subsonic Router with real DS + noop stubs
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(ds), metrics.NewNoopInstance())
router = subsonic.New(
ds,
noopArtwork{},
noopStreamer{},
noopArchiver{},
core.NewPlayers(ds),
noopProvider{},
s,
events.NoopBroker(),
core.NewPlaylists(ds),
noopPlayTracker{},
core.NewShare(ds),
playback.PlaybackServer(nil),
metrics.NewNoopInstance(),
)
}
// restoreDB restores all table data from the snapshot using ATTACH DATABASE.
// This is much faster than re-running the scanner for each test.
func restoreDB() {
sqlDB := db.Db()
_, err := sqlDB.Exec("PRAGMA foreign_keys = OFF")
Expect(err).ToNot(HaveOccurred())
_, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath)
Expect(err).ToNot(HaveOccurred())
rows, err := sqlDB.Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
Expect(err).ToNot(HaveOccurred())
var tables []string
for rows.Next() {
var name string
Expect(rows.Scan(&name)).To(Succeed())
tables = append(tables, name)
}
Expect(rows.Err()).ToNot(HaveOccurred())
rows.Close()
for _, table := range tables {
// Table names come from sqlite_master, not user input, so concatenation is safe here
_, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec
Expect(err).ToNot(HaveOccurred())
_, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec
Expect(err).ToNot(HaveOccurred())
}
_, err = sqlDB.Exec("DETACH DATABASE snapshot")
Expect(err).ToNot(HaveOccurred())
_, err = sqlDB.Exec("PRAGMA foreign_keys = ON")
Expect(err).ToNot(HaveOccurred())
}

View File

@@ -0,0 +1,350 @@
package e2e
import (
"net/http/httptest"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Album List Endpoints", func() {
BeforeEach(func() {
setupTestDB()
})
Describe("GetAlbumList", func() {
It("type=newest returns albums sorted by creation date", func() {
w, r := newRawReq("getAlbumList", "type", "newest")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(5))
})
It("type=alphabeticalByName sorts albums by name", func() {
w, r := newRawReq("getAlbumList", "type", "alphabeticalByName")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
albums := resp.AlbumList.Album
Expect(albums).To(HaveLen(5))
// Verify alphabetical order: Abbey Road, Help!, IV, Kind of Blue, Pop
Expect(albums[0].Title).To(Equal("Abbey Road"))
Expect(albums[1].Title).To(Equal("Help!"))
Expect(albums[2].Title).To(Equal("IV"))
Expect(albums[3].Title).To(Equal("Kind of Blue"))
Expect(albums[4].Title).To(Equal("Pop"))
})
It("type=alphabeticalByArtist sorts albums by artist name", func() {
w, r := newRawReq("getAlbumList", "type", "alphabeticalByArtist")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
albums := resp.AlbumList.Album
Expect(albums).To(HaveLen(5))
// Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles"
// Non-compilations first: Beatles (x2), Led Zeppelin, Miles Davis, then compilations: Various
Expect(albums[0].Artist).To(Equal("The Beatles"))
Expect(albums[2].Artist).To(Equal("Led Zeppelin"))
Expect(albums[3].Artist).To(Equal("Miles Davis"))
})
It("type=random returns albums", func() {
w, r := newRawReq("getAlbumList", "type", "random")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(5))
})
It("type=byGenre filters by genre parameter", func() {
w, r := newRawReq("getAlbumList", "type", "byGenre", "genre", "Jazz")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(1))
Expect(resp.AlbumList.Album[0].Title).To(Equal("Kind of Blue"))
})
It("type=byYear filters by fromYear/toYear range", func() {
w, r := newRawReq("getAlbumList", "type", "byYear", "fromYear", "1965", "toYear", "1970")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
// Should include Abbey Road (1969) and Help! (1965)
Expect(resp.AlbumList.Album).To(HaveLen(2))
years := make([]int32, len(resp.AlbumList.Album))
for i, a := range resp.AlbumList.Album {
years[i] = a.Year
}
Expect(years).To(ConsistOf(int32(1965), int32(1969)))
})
It("respects size parameter", func() {
w, r := newRawReq("getAlbumList", "type", "newest", "size", "2")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(2))
})
It("supports offset for pagination", func() {
// First get all albums sorted by name to know the expected order
w1, r1 := newRawReq("getAlbumList", "type", "alphabeticalByName", "size", "5")
resp1, err := router.GetAlbumList(w1, r1)
Expect(err).ToNot(HaveOccurred())
allAlbums := resp1.AlbumList.Album
// Now get with offset=2, size=2
w2, r2 := newRawReq("getAlbumList", "type", "alphabeticalByName", "size", "2", "offset", "2")
resp2, err := router.GetAlbumList(w2, r2)
Expect(err).ToNot(HaveOccurred())
Expect(resp2.AlbumList).ToNot(BeNil())
Expect(resp2.AlbumList.Album).To(HaveLen(2))
Expect(resp2.AlbumList.Album[0].Title).To(Equal(allAlbums[2].Title))
Expect(resp2.AlbumList.Album[1].Title).To(Equal(allAlbums[3].Title))
})
It("returns error when type parameter is missing", func() {
w := httptest.NewRecorder()
r := newReq("getAlbumList")
_, err := router.GetAlbumList(w, r)
Expect(err).To(HaveOccurred())
Expect(err).To(MatchError(req.ErrMissingParam))
})
It("returns error for unknown type", func() {
w, r := newRawReq("getAlbumList", "type", "invalid_type")
_, err := router.GetAlbumList(w, r)
Expect(err).To(HaveOccurred())
})
It("type=frequent returns empty when no albums have been played", func() {
w, r := newRawReq("getAlbumList", "type", "frequent")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(BeEmpty())
})
It("type=recent returns empty when no albums have been played", func() {
w, r := newRawReq("getAlbumList", "type", "recent")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(BeEmpty())
})
})
Describe("GetAlbumList - starred type", Ordered, func() {
BeforeAll(func() {
setupTestDB()
// Star an album so the starred filter returns results
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album.name": "Abbey Road"},
})
Expect(err).ToNot(HaveOccurred())
Expect(albums).ToNot(BeEmpty())
r := newReq("star", "albumId", albums[0].ID)
_, err = router.Star(r)
Expect(err).ToNot(HaveOccurred())
})
It("type=starred returns only starred albums", func() {
w, r := newRawReq("getAlbumList", "type", "starred")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(1))
Expect(resp.AlbumList.Album[0].Title).To(Equal("Abbey Road"))
})
})
Describe("GetAlbumList - highest type", Ordered, func() {
BeforeAll(func() {
setupTestDB()
// Rate an album so the highest filter returns results
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album.name": "Kind of Blue"},
})
Expect(err).ToNot(HaveOccurred())
Expect(albums).ToNot(BeEmpty())
r := newReq("setRating", "id", albums[0].ID, "rating", "5")
_, err = router.SetRating(r)
Expect(err).ToNot(HaveOccurred())
})
It("type=highest returns only rated albums", func() {
w, r := newRawReq("getAlbumList", "type", "highest")
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(1))
Expect(resp.AlbumList.Album[0].Title).To(Equal("Kind of Blue"))
})
})
Describe("GetAlbumList2", func() {
It("returns albums in AlbumID3 format", func() {
w, r := newRawReq("getAlbumList2", "type", "alphabeticalByName")
resp, err := router.GetAlbumList2(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.AlbumList2).ToNot(BeNil())
albums := resp.AlbumList2.Album
Expect(albums).To(HaveLen(5))
// Verify AlbumID3 format fields
Expect(albums[0].Name).To(Equal("Abbey Road"))
Expect(albums[0].Id).ToNot(BeEmpty())
Expect(albums[0].Artist).ToNot(BeEmpty())
})
It("type=newest works correctly", func() {
w, r := newRawReq("getAlbumList2", "type", "newest")
resp, err := router.GetAlbumList2(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList2).ToNot(BeNil())
Expect(resp.AlbumList2.Album).To(HaveLen(5))
})
})
Describe("GetStarred", func() {
It("returns empty lists when nothing is starred", func() {
r := newReq("getStarred")
resp, err := router.GetStarred(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Starred).ToNot(BeNil())
Expect(resp.Starred.Artist).To(BeEmpty())
Expect(resp.Starred.Album).To(BeEmpty())
Expect(resp.Starred.Song).To(BeEmpty())
})
})
Describe("GetStarred2", func() {
It("returns empty lists when nothing is starred", func() {
r := newReq("getStarred2")
resp, err := router.GetStarred2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Starred2).ToNot(BeNil())
Expect(resp.Starred2.Artist).To(BeEmpty())
Expect(resp.Starred2.Album).To(BeEmpty())
Expect(resp.Starred2.Song).To(BeEmpty())
})
})
Describe("GetNowPlaying", func() {
It("returns empty list when nobody is playing", func() {
r := newReq("getNowPlaying")
resp, err := router.GetNowPlaying(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.NowPlaying).ToNot(BeNil())
Expect(resp.NowPlaying.Entry).To(BeEmpty())
})
})
Describe("GetRandomSongs", func() {
It("returns random songs from library", func() {
r := newReq("getRandomSongs")
resp, err := router.GetRandomSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.RandomSongs).ToNot(BeNil())
Expect(resp.RandomSongs.Songs).ToNot(BeEmpty())
Expect(len(resp.RandomSongs.Songs)).To(BeNumerically("<=", 6))
})
It("respects size parameter", func() {
r := newReq("getRandomSongs", "size", "2")
resp, err := router.GetRandomSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.RandomSongs).ToNot(BeNil())
Expect(resp.RandomSongs.Songs).To(HaveLen(2))
})
It("filters by genre when specified", func() {
r := newReq("getRandomSongs", "size", "500", "genre", "Jazz")
resp, err := router.GetRandomSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.RandomSongs).ToNot(BeNil())
Expect(resp.RandomSongs.Songs).To(HaveLen(1))
Expect(resp.RandomSongs.Songs[0].Genre).To(Equal("Jazz"))
})
})
Describe("GetSongsByGenre", func() {
It("returns songs matching the genre", func() {
r := newReq("getSongsByGenre", "genre", "Rock")
resp, err := router.GetSongsByGenre(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.SongsByGenre).ToNot(BeNil())
// 4 Rock songs: Come Together, Something, Help!, Stairway To Heaven
Expect(resp.SongsByGenre.Songs).To(HaveLen(4))
for _, song := range resp.SongsByGenre.Songs {
Expect(song.Genre).To(Equal("Rock"))
}
})
It("supports count and offset parameters", func() {
// First get all Rock songs
r1 := newReq("getSongsByGenre", "genre", "Rock", "count", "500")
resp1, err := router.GetSongsByGenre(r1)
Expect(err).ToNot(HaveOccurred())
allSongs := resp1.SongsByGenre.Songs
// Now get with count=2, offset=1
r2 := newReq("getSongsByGenre", "genre", "Rock", "count", "2", "offset", "1")
resp2, err := router.GetSongsByGenre(r2)
Expect(err).ToNot(HaveOccurred())
Expect(resp2.SongsByGenre).ToNot(BeNil())
Expect(resp2.SongsByGenre.Songs).To(HaveLen(2))
Expect(resp2.SongsByGenre.Songs[0].Id).To(Equal(allSongs[1].Id))
})
It("returns empty for non-existent genre", func() {
r := newReq("getSongsByGenre", "genre", "NonExistentGenre")
resp, err := router.GetSongsByGenre(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SongsByGenre).ToNot(BeNil())
Expect(resp.SongsByGenre.Songs).To(BeEmpty())
})
})
})

View File

@@ -0,0 +1,164 @@
package e2e
import (
"fmt"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Bookmark and PlayQueue Endpoints", Ordered, func() {
BeforeAll(func() {
setupTestDB()
})
Describe("Bookmark Endpoints", Ordered, func() {
var trackID string
BeforeAll(func() {
// Get a media file ID from the database to use for bookmarks
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1})
Expect(err).ToNot(HaveOccurred())
Expect(mfs).ToNot(BeEmpty())
trackID = mfs[0].ID
})
It("getBookmarks returns empty initially", func() {
r := newReq("getBookmarks")
resp, err := router.GetBookmarks(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Bookmarks).ToNot(BeNil())
Expect(resp.Bookmarks.Bookmark).To(BeEmpty())
})
It("createBookmark creates a bookmark with position", func() {
r := newReq("createBookmark", "id", trackID, "position", "12345", "comment", "test bookmark")
resp, err := router.CreateBookmark(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("getBookmarks shows the created bookmark", func() {
r := newReq("getBookmarks")
resp, err := router.GetBookmarks(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Bookmarks).ToNot(BeNil())
Expect(resp.Bookmarks.Bookmark).To(HaveLen(1))
bmk := resp.Bookmarks.Bookmark[0]
Expect(bmk.Entry.Id).To(Equal(trackID))
Expect(bmk.Position).To(Equal(int64(12345)))
Expect(bmk.Comment).To(Equal("test bookmark"))
Expect(bmk.Username).To(Equal(adminUser.UserName))
})
It("deleteBookmark removes the bookmark", func() {
r := newReq("deleteBookmark", "id", trackID)
resp, err := router.DeleteBookmark(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify it's gone
r = newReq("getBookmarks")
resp, err = router.GetBookmarks(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Bookmarks.Bookmark).To(BeEmpty())
})
})
Describe("PlayQueue Endpoints", Ordered, func() {
var trackIDs []string
BeforeAll(func() {
// Get multiple media file IDs from the database
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 3, Sort: "title"})
Expect(err).ToNot(HaveOccurred())
Expect(len(mfs)).To(BeNumerically(">=", 2))
for _, mf := range mfs {
trackIDs = append(trackIDs, mf.ID)
}
})
It("getPlayQueue returns empty when nothing saved", func() {
r := newReq("getPlayQueue")
resp, err := router.GetPlayQueue(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// When no play queue exists, PlayQueue should be nil (no entry returned)
Expect(resp.PlayQueue).To(BeNil())
})
It("savePlayQueue stores current play queue", func() {
r := newReq("savePlayQueue",
"id", trackIDs[0],
"id", trackIDs[1],
"current", trackIDs[1],
"position", "5000",
)
resp, err := router.SavePlayQueue(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("getPlayQueue returns saved queue with tracks", func() {
r := newReq("getPlayQueue")
resp, err := router.GetPlayQueue(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.PlayQueue).ToNot(BeNil())
Expect(resp.PlayQueue.Entry).To(HaveLen(2))
Expect(resp.PlayQueue.Current).To(Equal(trackIDs[1]))
Expect(resp.PlayQueue.Position).To(Equal(int64(5000)))
Expect(resp.PlayQueue.Username).To(Equal(adminUser.UserName))
Expect(resp.PlayQueue.ChangedBy).To(Equal("test-client"))
})
It("getPlayQueueByIndex returns data with current index", func() {
r := newReq("getPlayQueueByIndex")
resp, err := router.GetPlayQueueByIndex(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.PlayQueueByIndex).ToNot(BeNil())
Expect(resp.PlayQueueByIndex.Entry).To(HaveLen(2))
Expect(resp.PlayQueueByIndex.CurrentIndex).ToNot(BeNil())
Expect(*resp.PlayQueueByIndex.CurrentIndex).To(Equal(1))
Expect(resp.PlayQueueByIndex.Position).To(Equal(int64(5000)))
})
It("savePlayQueueByIndex stores queue by index", func() {
r := newReq("savePlayQueueByIndex",
"id", trackIDs[0],
"id", trackIDs[1],
"id", trackIDs[2],
"currentIndex", fmt.Sprintf("%d", 0),
"position", "9999",
)
resp, err := router.SavePlayQueueByIndex(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify with getPlayQueueByIndex
r = newReq("getPlayQueueByIndex")
resp, err = router.GetPlayQueueByIndex(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.PlayQueueByIndex).ToNot(BeNil())
Expect(resp.PlayQueueByIndex.Entry).To(HaveLen(3))
Expect(resp.PlayQueueByIndex.CurrentIndex).ToNot(BeNil())
Expect(*resp.PlayQueueByIndex.CurrentIndex).To(Equal(0))
Expect(resp.PlayQueueByIndex.Position).To(Equal(int64(9999)))
})
})
})

View File

@@ -0,0 +1,522 @@
package e2e
import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Browsing Endpoints", func() {
BeforeEach(func() {
setupTestDB()
})
Describe("getMusicFolders", func() {
It("returns the configured music library", func() {
r := newReq("getMusicFolders")
resp, err := router.GetMusicFolders(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.MusicFolders).ToNot(BeNil())
Expect(resp.MusicFolders.Folders).To(HaveLen(1))
Expect(resp.MusicFolders.Folders[0].Name).To(Equal("Music Library"))
Expect(resp.MusicFolders.Folders[0].Id).To(Equal(int32(lib.ID)))
})
})
Describe("getIndexes", func() {
It("returns artist indexes", func() {
r := newReq("getIndexes")
resp, err := router.GetIndexes(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Indexes).ToNot(BeNil())
Expect(resp.Indexes.Index).ToNot(BeEmpty())
})
It("includes all artists across indexes", func() {
r := newReq("getIndexes")
resp, err := router.GetIndexes(r)
Expect(err).ToNot(HaveOccurred())
var allArtistNames []string
for _, idx := range resp.Indexes.Index {
for _, a := range idx.Artists {
allArtistNames = append(allArtistNames, a.Name)
}
}
Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various"))
})
})
Describe("getArtists", func() {
It("returns artist indexes in ID3 format", func() {
r := newReq("getArtists")
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Artist).ToNot(BeNil())
Expect(resp.Artist.Index).ToNot(BeEmpty())
})
It("includes all artists across ID3 indexes", func() {
r := newReq("getArtists")
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
var allArtistNames []string
for _, idx := range resp.Artist.Index {
for _, a := range idx.Artists {
allArtistNames = append(allArtistNames, a.Name)
}
}
Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various"))
})
It("reports correct album counts for artists", func() {
r := newReq("getArtists")
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
var beatlesAlbumCount int32
for _, idx := range resp.Artist.Index {
for _, a := range idx.Artists {
if a.Name == "The Beatles" {
beatlesAlbumCount = a.AlbumCount
}
}
}
Expect(beatlesAlbumCount).To(Equal(int32(2)))
})
})
Describe("getMusicDirectory", func() {
It("returns an artist directory with its albums as children", func() {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "The Beatles"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
beatlesID := artists[0].ID
r := newReq("getMusicDirectory", "id", beatlesID)
resp, err := router.GetMusicDirectory(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Directory).ToNot(BeNil())
Expect(resp.Directory.Name).To(Equal("The Beatles"))
Expect(resp.Directory.Child).To(HaveLen(2)) // Abbey Road, Help!
})
It("returns an album directory with its tracks as children", func() {
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album.name": "Abbey Road"},
})
Expect(err).ToNot(HaveOccurred())
Expect(albums).ToNot(BeEmpty())
abbeyRoadID := albums[0].ID
r := newReq("getMusicDirectory", "id", abbeyRoadID)
resp, err := router.GetMusicDirectory(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Directory).ToNot(BeNil())
Expect(resp.Directory.Name).To(Equal("Abbey Road"))
Expect(resp.Directory.Child).To(HaveLen(2)) // Come Together, Something
})
It("returns an error for a non-existent ID", func() {
r := newReq("getMusicDirectory", "id", "non-existent-id")
_, err := router.GetMusicDirectory(r)
Expect(err).To(HaveOccurred())
})
})
Describe("getArtist", func() {
It("returns artist with albums in ID3 format", func() {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "The Beatles"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
beatlesID := artists[0].ID
r := newReq("getArtist", "id", beatlesID)
resp, err := router.GetArtist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.ArtistWithAlbumsID3).ToNot(BeNil())
Expect(resp.ArtistWithAlbumsID3.Name).To(Equal("The Beatles"))
Expect(resp.ArtistWithAlbumsID3.Album).To(HaveLen(2))
})
It("returns album names for the artist", func() {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "The Beatles"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
beatlesID := artists[0].ID
r := newReq("getArtist", "id", beatlesID)
resp, err := router.GetArtist(r)
Expect(err).ToNot(HaveOccurred())
var albumNames []string
for _, a := range resp.ArtistWithAlbumsID3.Album {
albumNames = append(albumNames, a.Name)
}
Expect(albumNames).To(ContainElements("Abbey Road", "Help!"))
})
It("returns an error for a non-existent artist", func() {
r := newReq("getArtist", "id", "non-existent-id")
_, err := router.GetArtist(r)
Expect(err).To(HaveOccurred())
})
It("returns artist with a single album", func() {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "Led Zeppelin"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
ledZepID := artists[0].ID
r := newReq("getArtist", "id", ledZepID)
resp, err := router.GetArtist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.ArtistWithAlbumsID3).ToNot(BeNil())
Expect(resp.ArtistWithAlbumsID3.Name).To(Equal("Led Zeppelin"))
Expect(resp.ArtistWithAlbumsID3.Album).To(HaveLen(1))
Expect(resp.ArtistWithAlbumsID3.Album[0].Name).To(Equal("IV"))
})
})
Describe("getAlbum", func() {
It("returns album with its tracks", func() {
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album.name": "Abbey Road"},
})
Expect(err).ToNot(HaveOccurred())
Expect(albums).ToNot(BeEmpty())
abbeyRoadID := albums[0].ID
r := newReq("getAlbum", "id", abbeyRoadID)
resp, err := router.GetAlbum(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.AlbumWithSongsID3).ToNot(BeNil())
Expect(resp.AlbumWithSongsID3.Name).To(Equal("Abbey Road"))
Expect(resp.AlbumWithSongsID3.Song).To(HaveLen(2))
})
It("includes correct track metadata", func() {
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album.name": "Abbey Road"},
})
Expect(err).ToNot(HaveOccurred())
Expect(albums).ToNot(BeEmpty())
abbeyRoadID := albums[0].ID
r := newReq("getAlbum", "id", abbeyRoadID)
resp, err := router.GetAlbum(r)
Expect(err).ToNot(HaveOccurred())
var trackTitles []string
for _, s := range resp.AlbumWithSongsID3.Song {
trackTitles = append(trackTitles, s.Title)
}
Expect(trackTitles).To(ContainElements("Come Together", "Something"))
})
It("returns album with correct artist and year", func() {
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album.name": "Kind of Blue"},
})
Expect(err).ToNot(HaveOccurred())
Expect(albums).ToNot(BeEmpty())
kindOfBlueID := albums[0].ID
r := newReq("getAlbum", "id", kindOfBlueID)
resp, err := router.GetAlbum(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumWithSongsID3).ToNot(BeNil())
Expect(resp.AlbumWithSongsID3.Name).To(Equal("Kind of Blue"))
Expect(resp.AlbumWithSongsID3.Artist).To(Equal("Miles Davis"))
Expect(resp.AlbumWithSongsID3.Year).To(Equal(int32(1959)))
Expect(resp.AlbumWithSongsID3.Song).To(HaveLen(1))
})
It("returns an error for a non-existent album", func() {
r := newReq("getAlbum", "id", "non-existent-id")
_, err := router.GetAlbum(r)
Expect(err).To(HaveOccurred())
})
})
Describe("getSong", func() {
It("returns a song by its ID", func() {
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Come Together"},
})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
songID := songs[0].ID
r := newReq("getSong", "id", songID)
resp, err := router.GetSong(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Song).ToNot(BeNil())
Expect(resp.Song.Title).To(Equal("Come Together"))
Expect(resp.Song.Album).To(Equal("Abbey Road"))
Expect(resp.Song.Artist).To(Equal("The Beatles"))
})
It("returns an error for a non-existent song", func() {
r := newReq("getSong", "id", "non-existent-id")
_, err := router.GetSong(r)
Expect(err).To(HaveOccurred())
})
It("returns correct metadata for a jazz track", func() {
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "So What"},
})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
songID := songs[0].ID
r := newReq("getSong", "id", songID)
resp, err := router.GetSong(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Song).ToNot(BeNil())
Expect(resp.Song.Title).To(Equal("So What"))
Expect(resp.Song.Album).To(Equal("Kind of Blue"))
Expect(resp.Song.Artist).To(Equal("Miles Davis"))
})
})
Describe("getGenres", func() {
It("returns all genres", func() {
r := newReq("getGenres")
resp, err := router.GetGenres(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Genres).ToNot(BeNil())
Expect(resp.Genres.Genre).To(HaveLen(3))
})
It("includes correct genre names", func() {
r := newReq("getGenres")
resp, err := router.GetGenres(r)
Expect(err).ToNot(HaveOccurred())
var genreNames []string
for _, g := range resp.Genres.Genre {
genreNames = append(genreNames, g.Name)
}
Expect(genreNames).To(ContainElements("Rock", "Jazz", "Pop"))
})
It("reports correct song and album counts for Rock", func() {
r := newReq("getGenres")
resp, err := router.GetGenres(r)
Expect(err).ToNot(HaveOccurred())
var rockGenre *responses.Genre
for i, g := range resp.Genres.Genre {
if g.Name == "Rock" {
rockGenre = &resp.Genres.Genre[i]
break
}
}
Expect(rockGenre).ToNot(BeNil())
Expect(rockGenre.SongCount).To(Equal(int32(4)))
Expect(rockGenre.AlbumCount).To(Equal(int32(3)))
})
It("reports correct song and album counts for Jazz", func() {
r := newReq("getGenres")
resp, err := router.GetGenres(r)
Expect(err).ToNot(HaveOccurred())
var jazzGenre *responses.Genre
for i, g := range resp.Genres.Genre {
if g.Name == "Jazz" {
jazzGenre = &resp.Genres.Genre[i]
break
}
}
Expect(jazzGenre).ToNot(BeNil())
Expect(jazzGenre.SongCount).To(Equal(int32(1)))
Expect(jazzGenre.AlbumCount).To(Equal(int32(1)))
})
It("reports correct song and album counts for Pop", func() {
r := newReq("getGenres")
resp, err := router.GetGenres(r)
Expect(err).ToNot(HaveOccurred())
var popGenre *responses.Genre
for i, g := range resp.Genres.Genre {
if g.Name == "Pop" {
popGenre = &resp.Genres.Genre[i]
break
}
}
Expect(popGenre).ToNot(BeNil())
Expect(popGenre.SongCount).To(Equal(int32(1)))
Expect(popGenre.AlbumCount).To(Equal(int32(1)))
})
})
Describe("getAlbumInfo", func() {
It("returns album info for a valid album", func() {
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album.name": "Abbey Road"},
})
Expect(err).ToNot(HaveOccurred())
Expect(albums).ToNot(BeEmpty())
abbeyRoadID := albums[0].ID
r := newReq("getAlbumInfo", "id", abbeyRoadID)
resp, err := router.GetAlbumInfo(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.AlbumInfo).ToNot(BeNil())
})
})
Describe("getAlbumInfo2", func() {
It("returns album info for a valid album", func() {
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album.name": "Abbey Road"},
})
Expect(err).ToNot(HaveOccurred())
Expect(albums).ToNot(BeEmpty())
abbeyRoadID := albums[0].ID
r := newReq("getAlbumInfo2", "id", abbeyRoadID)
resp, err := router.GetAlbumInfo(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.AlbumInfo).ToNot(BeNil())
})
})
Describe("getArtistInfo", func() {
It("returns artist info for a valid artist", func() {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "The Beatles"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
beatlesID := artists[0].ID
r := newReq("getArtistInfo", "id", beatlesID)
resp, err := router.GetArtistInfo(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.ArtistInfo).ToNot(BeNil())
})
})
Describe("getArtistInfo2", func() {
It("returns artist info2 for a valid artist", func() {
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"name": "The Beatles"},
})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
beatlesID := artists[0].ID
r := newReq("getArtistInfo2", "id", beatlesID)
resp, err := router.GetArtistInfo2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.ArtistInfo2).ToNot(BeNil())
})
})
Describe("getTopSongs", func() {
It("returns a response for a known artist name", func() {
r := newReq("getTopSongs", "artist", "The Beatles")
resp, err := router.GetTopSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.TopSongs).ToNot(BeNil())
// noopProvider returns empty list, so Songs may be empty
})
It("returns an empty list for an unknown artist", func() {
r := newReq("getTopSongs", "artist", "Unknown Artist")
resp, err := router.GetTopSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.TopSongs).ToNot(BeNil())
Expect(resp.TopSongs.Song).To(BeEmpty())
})
})
Describe("getSimilarSongs", func() {
It("returns a response for a valid song ID", func() {
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Come Together"},
})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
songID := songs[0].ID
r := newReq("getSimilarSongs", "id", songID)
resp, err := router.GetSimilarSongs(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.SimilarSongs).ToNot(BeNil())
// noopProvider returns empty list
})
})
Describe("getSimilarSongs2", func() {
It("returns a response for a valid song ID", func() {
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Come Together"},
})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
songID := songs[0].ID
r := newReq("getSimilarSongs2", "id", songID)
resp, err := router.GetSimilarSongs2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.SimilarSongs2).ToNot(BeNil())
// noopProvider returns empty list
})
})
})

View File

@@ -0,0 +1,186 @@
package e2e
import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Media Annotation Endpoints", Ordered, func() {
BeforeAll(func() {
setupTestDB()
})
Describe("Star/Unstar", Ordered, func() {
var songID, albumID, artistID string
BeforeAll(func() {
// Look up a song from the scanned data
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
songID = songs[0].ID
// Look up an album
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"})
Expect(err).ToNot(HaveOccurred())
Expect(albums).ToNot(BeEmpty())
albumID = albums[0].ID
// Look up an artist
artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"})
Expect(err).ToNot(HaveOccurred())
Expect(artists).ToNot(BeEmpty())
artistID = artists[0].ID
})
It("stars a song by id", func() {
r := newReq("star", "id", songID)
resp, err := router.Star(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("starred song appears in getStarred response", func() {
r := newReq("getStarred")
resp, err := router.GetStarred(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Starred).ToNot(BeNil())
Expect(resp.Starred.Song).To(HaveLen(1))
Expect(resp.Starred.Song[0].Id).To(Equal(songID))
})
It("unstars a previously starred song", func() {
r := newReq("unstar", "id", songID)
resp, err := router.Unstar(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify song no longer appears in starred
r = newReq("getStarred")
resp, err = router.GetStarred(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Starred.Song).To(BeEmpty())
})
It("stars an album by albumId", func() {
r := newReq("star", "albumId", albumID)
resp, err := router.Star(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify album appears in starred
r = newReq("getStarred")
resp, err = router.GetStarred(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Starred.Album).To(HaveLen(1))
Expect(resp.Starred.Album[0].Id).To(Equal(albumID))
})
It("stars an artist by artistId", func() {
r := newReq("star", "artistId", artistID)
resp, err := router.Star(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify artist appears in starred
r = newReq("getStarred")
resp, err = router.GetStarred(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Starred.Artist).To(HaveLen(1))
Expect(resp.Starred.Artist[0].Id).To(Equal(artistID))
})
It("returns error when no id provided", func() {
r := newReq("star")
_, err := router.Star(r)
Expect(err).To(HaveOccurred())
})
})
Describe("SetRating", Ordered, func() {
var songID, albumID string
BeforeAll(func() {
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
songID = songs[0].ID
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "name"})
Expect(err).ToNot(HaveOccurred())
Expect(albums).ToNot(BeEmpty())
albumID = albums[0].ID
})
It("sets rating on a song", func() {
r := newReq("setRating", "id", songID, "rating", "4")
resp, err := router.SetRating(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("rated song has correct userRating in getSong", func() {
r := newReq("getSong", "id", songID)
resp, err := router.GetSong(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Song).ToNot(BeNil())
Expect(resp.Song.UserRating).To(Equal(int32(4)))
})
It("sets rating on an album", func() {
r := newReq("setRating", "id", albumID, "rating", "3")
resp, err := router.SetRating(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("returns error for missing parameters", func() {
// Missing both id and rating
r := newReq("setRating")
_, err := router.SetRating(r)
Expect(err).To(HaveOccurred())
// Missing rating
r = newReq("setRating", "id", songID)
_, err = router.SetRating(r)
Expect(err).To(HaveOccurred())
})
})
Describe("Scrobble", func() {
It("submits a scrobble for a song", func() {
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
r := newReq("scrobble", "id", songs[0].ID, "submission", "true")
resp, err := router.Scrobble(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("returns error when id is missing", func() {
r := newReq("scrobble")
_, err := router.Scrobble(r)
Expect(err).To(HaveOccurred())
})
})
})

View File

@@ -0,0 +1,79 @@
package e2e
import (
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Media Retrieval Endpoints", Ordered, func() {
BeforeAll(func() {
setupTestDB()
})
Describe("Stream", func() {
It("returns error when id parameter is missing", func() {
w, r := newRawReq("stream")
_, err := router.Stream(w, r)
Expect(err).To(HaveOccurred())
})
})
Describe("Download", func() {
It("returns error when id parameter is missing", func() {
w, r := newRawReq("download")
_, err := router.Download(w, r)
Expect(err).To(HaveOccurred())
})
})
Describe("GetCoverArt", func() {
It("handles request without error", func() {
w, r := newRawReq("getCoverArt")
_, err := router.GetCoverArt(w, r)
Expect(err).ToNot(HaveOccurred())
})
})
Describe("GetAvatar", func() {
It("returns placeholder avatar when gravatar disabled", func() {
w, r := newRawReq("getAvatar", "username", "admin")
resp, err := router.GetAvatar(w, r)
// When gravatar is disabled, it returns nil response (writes directly to w)
Expect(err).ToNot(HaveOccurred())
Expect(resp).To(BeNil())
})
})
Describe("GetLyrics", func() {
It("returns empty lyrics when no match found", func() {
r := newReq("getLyrics", "artist", "NonExistentArtist", "title", "NonExistentTitle")
resp, err := router.GetLyrics(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Lyrics).ToNot(BeNil())
Expect(resp.Lyrics.Value).To(BeEmpty())
})
})
Describe("GetLyricsBySongId", func() {
It("returns error when id parameter is missing", func() {
r := newReq("getLyricsBySongId")
_, err := router.GetLyricsBySongId(r)
Expect(err).To(HaveOccurred())
})
It("returns error for non-existent song id", func() {
r := newReq("getLyricsBySongId", "id", "non-existent-id")
_, err := router.GetLyricsBySongId(r)
Expect(err).To(HaveOccurred())
})
})
})

View File

@@ -0,0 +1,312 @@
package e2e
import (
"fmt"
"testing/fstest"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Multi-Library Support", Ordered, func() {
var lib2 model.Library
var adminWithLibs model.User // admin reloaded with both libraries
var userLib1Only model.User // non-admin with lib1 access only
BeforeAll(func() {
setupTestDB()
// Create a second FakeFS with Classical music content
classical := template(_t{
"albumartist": "Ludwig van Beethoven",
"artist": "Ludwig van Beethoven",
"album": "Symphony No. 9",
"year": 1824,
"genre": "Classical",
})
classicalFS := storagetest.FakeFS{}
classicalFS.SetFiles(fstest.MapFS{
"Classical/Beethoven/Symphony No. 9/01 - Allegro ma non troppo.mp3": classical(track(1, "Allegro ma non troppo")),
"Classical/Beethoven/Symphony No. 9/02 - Ode to Joy.mp3": classical(track(2, "Ode to Joy")),
})
storagetest.Register("fake2", &classicalFS)
// Create the second library in the DB (Put auto-assigns admin users)
lib2 = model.Library{ID: 2, Name: "Classical Library", Path: "fake2:///classical"}
Expect(ds.Library(ctx).Put(&lib2)).To(Succeed())
// Reload admin user to get both libraries in the Libraries field
loadedAdmin, err := ds.User(ctx).FindByUsername(adminUser.UserName)
Expect(err).ToNot(HaveOccurred())
adminWithLibs = *loadedAdmin
// Run incremental scan to import lib2 content (lib1 files unchanged → skipped)
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
core.NewPlaylists(ds), metrics.NewNoopInstance())
_, err = s.ScanAll(ctx, false)
Expect(err).ToNot(HaveOccurred())
// Create a non-admin user with access only to lib1
userLib1Only = model.User{
ID: "multilib-user-1",
UserName: "lib1user",
Name: "Lib1 User",
IsAdmin: false,
NewPassword: "password",
}
Expect(ds.User(ctx).Put(&userLib1Only)).To(Succeed())
Expect(ds.User(ctx).SetUserLibraries(userLib1Only.ID, []int{lib.ID})).To(Succeed())
loadedUser, err := ds.User(ctx).FindByUsername(userLib1Only.UserName)
Expect(err).ToNot(HaveOccurred())
userLib1Only.Libraries = loadedUser.Libraries
})
Describe("getMusicFolders", func() {
It("returns both libraries for admin user", func() {
r := newReqWithUser(adminWithLibs, "getMusicFolders")
resp, err := router.GetMusicFolders(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.MusicFolders.Folders).To(HaveLen(2))
names := make([]string, len(resp.MusicFolders.Folders))
for i, f := range resp.MusicFolders.Folders {
names[i] = f.Name
}
Expect(names).To(ConsistOf("Music Library", "Classical Library"))
})
})
Describe("getArtists - library filtering", func() {
It("returns only lib1 artists when musicFolderId=1", func() {
r := newReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib.ID))
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Artist).ToNot(BeNil())
var artistNames []string
for _, idx := range resp.Artist.Index {
for _, a := range idx.Artists {
artistNames = append(artistNames, a.Name)
}
}
Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis"))
Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven"))
})
It("returns only lib2 artists when musicFolderId=2", func() {
r := newReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Artist).ToNot(BeNil())
var artistNames []string
for _, idx := range resp.Artist.Index {
for _, a := range idx.Artists {
artistNames = append(artistNames, a.Name)
}
}
Expect(artistNames).To(ContainElement("Ludwig van Beethoven"))
Expect(artistNames).ToNot(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis"))
})
It("returns artists from all libraries when no musicFolderId is specified", func() {
r := newReqWithUser(adminWithLibs, "getArtists")
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
var artistNames []string
for _, idx := range resp.Artist.Index {
for _, a := range idx.Artists {
artistNames = append(artistNames, a.Name)
}
}
Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Ludwig van Beethoven"))
})
})
Describe("getAlbumList - library filtering", func() {
It("returns only lib1 albums when musicFolderId=1", func() {
w, r := newRawReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID))
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(5))
for _, a := range resp.AlbumList.Album {
Expect(a.Title).ToNot(Equal("Symphony No. 9"))
}
})
It("returns only lib2 albums when musicFolderId=2", func() {
w, r := newRawReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
resp, err := router.GetAlbumList(w, r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.AlbumList).ToNot(BeNil())
Expect(resp.AlbumList.Album).To(HaveLen(1))
Expect(resp.AlbumList.Album[0].Title).To(Equal("Symphony No. 9"))
})
})
Describe("search3 - library filtering", func() {
It("does not find lib1 content when searching in lib2 only", func() {
r := newReqWithUser(adminWithLibs, "search3", "query", "Beatles", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
resp, err := router.Search3(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Artist).To(BeEmpty())
Expect(resp.SearchResult3.Album).To(BeEmpty())
Expect(resp.SearchResult3.Song).To(BeEmpty())
})
It("finds lib2 content when searching in lib2", func() {
r := newReqWithUser(adminWithLibs, "search3", "query", "Beethoven", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
resp, err := router.Search3(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Artist).ToNot(BeEmpty())
Expect(resp.SearchResult3.Artist[0].Name).To(Equal("Ludwig van Beethoven"))
})
})
Describe("Cross-library playlists", Ordered, func() {
var playlistID string
var lib1SongID, lib2SongID string
BeforeAll(func() {
// Look up one song from each library
lib1Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"media_file.library_id": lib.ID},
Max: 1, Sort: "title",
})
Expect(err).ToNot(HaveOccurred())
Expect(lib1Songs).ToNot(BeEmpty())
lib1SongID = lib1Songs[0].ID
lib2Songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"media_file.library_id": lib2.ID},
Max: 1, Sort: "title",
})
Expect(err).ToNot(HaveOccurred())
Expect(lib2Songs).ToNot(BeEmpty())
lib2SongID = lib2Songs[0].ID
})
It("admin creates a playlist with songs from both libraries", func() {
r := newReqWithUser(adminWithLibs, "createPlaylist",
"name", "Cross-Library Playlist", "songId", lib1SongID, "songId", lib2SongID)
resp, err := router.CreatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlist).ToNot(BeNil())
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
Expect(resp.Playlist.Entry).To(HaveLen(2))
playlistID = resp.Playlist.Id
})
It("admin makes the playlist public", func() {
r := newReqWithUser(adminWithLibs, "updatePlaylist",
"playlistId", playlistID, "public", "true")
resp, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("non-admin user with lib1 only sees only lib1 tracks in the playlist", func() {
// Reset the cached playlist repo so it's recreated with the non-admin user's context.
// The MockDataStore caches repos on first access; resetting forces a new repo
// whose applyLibraryFilter uses the non-admin user's library access.
ds.MockedPlaylist = nil
r := newReqWithUser(userLib1Only, "getPlaylist", "id", playlistID)
resp, err := router.GetPlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Playlist).ToNot(BeNil())
// The playlist has 2 songs total, but the non-admin user only has access to lib1
Expect(resp.Playlist.Entry).To(HaveLen(1))
Expect(resp.Playlist.Entry[0].Id).To(Equal(lib1SongID))
})
})
Describe("Cross-library shares", Ordered, func() {
var lib2AlbumID string
BeforeAll(func() {
conf.Server.EnableSharing = true
lib2Albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album.library_id": lib2.ID},
})
Expect(err).ToNot(HaveOccurred())
Expect(lib2Albums).ToNot(BeEmpty())
lib2AlbumID = lib2Albums[0].ID
})
It("admin creates a share for a lib2 album", func() {
r := newReqWithUser(adminWithLibs, "createShare",
"id", lib2AlbumID, "description", "Classical album share")
resp, err := router.CreateShare(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Shares).ToNot(BeNil())
Expect(resp.Shares.Share).To(HaveLen(1))
share := resp.Shares.Share[0]
Expect(share.Description).To(Equal("Classical album share"))
Expect(share.Entry).ToNot(BeEmpty())
Expect(share.Entry[0].Title).To(Equal("Symphony No. 9"))
})
})
Describe("Library access control", func() {
It("returns error when non-admin user requests inaccessible library", func() {
r := newReqWithUser(userLib1Only, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID))
_, err := router.GetArtists(r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not found"))
})
It("non-admin user sees only their library's content without musicFolderId", func() {
r := newReqWithUser(userLib1Only, "getArtists")
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
var artistNames []string
for _, idx := range resp.Artist.Index {
for _, a := range idx.Artists {
artistNames = append(artistNames, a.Name)
}
}
Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis"))
Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven"))
})
})
})

View File

@@ -0,0 +1,97 @@
package e2e
import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Multi-User Isolation", Ordered, func() {
var regularUser model.User
BeforeAll(func() {
setupTestDB()
// Create a regular (non-admin) user
regularUser = model.User{
ID: "regular-1",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
NewPassword: "password",
}
Expect(ds.User(ctx).Put(&regularUser)).To(Succeed())
Expect(ds.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
loadedUser, err := ds.User(ctx).FindByUsername(regularUser.UserName)
Expect(err).ToNot(HaveOccurred())
regularUser.Libraries = loadedUser.Libraries
})
Describe("Admin-only endpoint restrictions", func() {
It("startScan fails for regular user", func() {
r := newReqWithUser(regularUser, "startScan")
_, err := router.StartScan(r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not authorized"))
})
})
Describe("Browsing as regular user", func() {
It("regular user can browse the library", func() {
r := newReqWithUser(regularUser, "getArtists")
resp, err := router.GetArtists(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Artist).ToNot(BeNil())
Expect(resp.Artist.Index).ToNot(BeEmpty())
})
It("regular user can search", func() {
r := newReqWithUser(regularUser, "search3", "query", "Beatles")
resp, err := router.Search3(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.SearchResult3).ToNot(BeNil())
Expect(resp.SearchResult3.Artist).ToNot(BeEmpty())
})
})
Describe("getUser authorization", func() {
It("regular user can get their own info", func() {
r := newReqWithUser(regularUser, "getUser", "username", "regular")
resp, err := router.GetUser(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.User.Username).To(Equal("regular"))
Expect(resp.User.AdminRole).To(BeFalse())
})
It("regular user cannot get another user's info", func() {
r := newReqWithUser(regularUser, "getUser", "username", "admin")
_, err := router.GetUser(r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not authorized"))
})
})
Describe("getUsers for regular user", func() {
It("returns only the requesting user's info", func() {
r := newReqWithUser(regularUser, "getUsers")
resp, err := router.GetUsers(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Users).ToNot(BeNil())
Expect(resp.Users.User).To(HaveLen(1))
Expect(resp.Users.User[0].Username).To(Equal("regular"))
Expect(resp.Users.User[0].AdminRole).To(BeFalse())
})
})
})

View File

@@ -0,0 +1,130 @@
package e2e
import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Playlist Endpoints", Ordered, func() {
var playlistID string
var songIDs []string
BeforeAll(func() {
setupTestDB()
// Look up song IDs from scanned data for playlist operations
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 3})
Expect(err).ToNot(HaveOccurred())
Expect(len(songs)).To(BeNumerically(">=", 3))
for _, s := range songs {
songIDs = append(songIDs, s.ID)
}
})
It("getPlaylists returns empty list initially", func() {
r := newReq("getPlaylists")
resp, err := router.GetPlaylists(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlists).ToNot(BeNil())
Expect(resp.Playlists.Playlist).To(BeEmpty())
})
It("createPlaylist creates a new playlist with songs", func() {
r := newReq("createPlaylist", "name", "Test Playlist", "songId", songIDs[0], "songId", songIDs[1])
resp, err := router.CreatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlist).ToNot(BeNil())
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
playlistID = resp.Playlist.Id
})
It("getPlaylist returns playlist with tracks", func() {
r := newReq("getPlaylist", "id", playlistID)
resp, err := router.GetPlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Playlist).ToNot(BeNil())
Expect(resp.Playlist.Name).To(Equal("Test Playlist"))
Expect(resp.Playlist.Entry).To(HaveLen(2))
Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0]))
Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1]))
})
It("createPlaylist without name or playlistId returns error", func() {
r := newReq("createPlaylist", "songId", songIDs[0])
_, err := router.CreatePlaylist(r)
Expect(err).To(HaveOccurred())
})
It("updatePlaylist can rename the playlist", func() {
r := newReq("updatePlaylist", "playlistId", playlistID, "name", "Renamed Playlist")
resp, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify the rename
r = newReq("getPlaylist", "id", playlistID)
resp, err = router.GetPlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Playlist.Name).To(Equal("Renamed Playlist"))
})
It("updatePlaylist can add songs", func() {
r := newReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[2])
resp, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify the song was added
r = newReq("getPlaylist", "id", playlistID)
resp, err = router.GetPlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Playlist.SongCount).To(Equal(int32(3)))
Expect(resp.Playlist.Entry).To(HaveLen(3))
})
It("updatePlaylist can remove songs by index", func() {
// Remove the first song (index 0)
r := newReq("updatePlaylist", "playlistId", playlistID, "songIndexToRemove", "0")
resp, err := router.UpdatePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify the song was removed
r = newReq("getPlaylist", "id", playlistID)
resp, err = router.GetPlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Playlist.SongCount).To(Equal(int32(2)))
Expect(resp.Playlist.Entry).To(HaveLen(2))
})
It("deletePlaylist removes the playlist", func() {
r := newReq("deletePlaylist", "id", playlistID)
resp, err := router.DeletePlaylist(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("getPlaylist on deleted playlist returns error", func() {
r := newReq("getPlaylist", "id", playlistID)
_, err := router.GetPlaylist(r)
Expect(err).To(HaveOccurred())
})
})

View File

@@ -0,0 +1,94 @@
package e2e
import (
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Internet Radio Endpoints", Ordered, func() {
var radioID string
BeforeAll(func() {
setupTestDB()
})
It("getInternetRadioStations returns empty initially", func() {
r := newReq("getInternetRadioStations")
resp, err := router.GetInternetRadios(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.InternetRadioStations).ToNot(BeNil())
Expect(resp.InternetRadioStations.Radios).To(BeEmpty())
})
It("createInternetRadioStation adds a station", func() {
r := newReq("createInternetRadioStation",
"streamUrl", "https://stream.example.com/radio",
"name", "Test Radio",
"homepageUrl", "https://example.com",
)
resp, err := router.CreateInternetRadio(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("getInternetRadioStations returns the created station", func() {
r := newReq("getInternetRadioStations")
resp, err := router.GetInternetRadios(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.InternetRadioStations).ToNot(BeNil())
Expect(resp.InternetRadioStations.Radios).To(HaveLen(1))
radio := resp.InternetRadioStations.Radios[0]
Expect(radio.Name).To(Equal("Test Radio"))
Expect(radio.StreamUrl).To(Equal("https://stream.example.com/radio"))
Expect(radio.HomepageUrl).To(Equal("https://example.com"))
radioID = radio.ID
Expect(radioID).ToNot(BeEmpty())
})
It("updateInternetRadioStation modifies the station", func() {
r := newReq("updateInternetRadioStation",
"id", radioID,
"streamUrl", "https://stream.example.com/radio-v2",
"name", "Updated Radio",
"homepageUrl", "https://updated.example.com",
)
resp, err := router.UpdateInternetRadio(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify update
r = newReq("getInternetRadioStations")
resp, err = router.GetInternetRadios(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.InternetRadioStations.Radios).To(HaveLen(1))
Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Updated Radio"))
Expect(resp.InternetRadioStations.Radios[0].StreamUrl).To(Equal("https://stream.example.com/radio-v2"))
Expect(resp.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("https://updated.example.com"))
})
It("deleteInternetRadioStation removes it", func() {
r := newReq("deleteInternetRadioStation", "id", radioID)
resp, err := router.DeleteInternetRadio(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("getInternetRadioStations returns empty after deletion", func() {
r := newReq("getInternetRadioStations")
resp, err := router.GetInternetRadios(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.InternetRadioStations).ToNot(BeNil())
Expect(resp.InternetRadioStations.Radios).To(BeEmpty())
})
})

View File

@@ -0,0 +1,60 @@
package e2e
import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Scan Endpoints", func() {
BeforeEach(func() {
setupTestDB()
})
It("getScanStatus returns status", func() {
r := newReq("getScanStatus")
resp, err := router.GetScanStatus(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.ScanStatus).ToNot(BeNil())
Expect(resp.ScanStatus.Scanning).To(BeFalse())
Expect(resp.ScanStatus.Count).To(BeNumerically(">", 0))
Expect(resp.ScanStatus.LastScan).ToNot(BeNil())
})
It("startScan requires admin user", func() {
regularUser := model.User{
ID: "user-2",
UserName: "regular",
Name: "Regular User",
IsAdmin: false,
}
// Store the regular user in the database
regularUserWithPass := regularUser
regularUserWithPass.NewPassword = "password"
Expect(ds.User(ctx).Put(&regularUserWithPass)).To(Succeed())
Expect(ds.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
// Reload user with libraries
loadedUser, err := ds.User(ctx).FindByUsername(regularUser.UserName)
Expect(err).ToNot(HaveOccurred())
regularUser.Libraries = loadedUser.Libraries
r := newReqWithUser(regularUser, "startScan")
_, err = router.StartScan(r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not authorized"))
})
It("startScan returns scan status response", func() {
r := newReq("startScan")
resp, err := router.StartScan(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.ScanStatus).ToNot(BeNil())
})
})

View File

@@ -0,0 +1,158 @@
package e2e
import (
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Search Endpoints", func() {
BeforeEach(func() {
setupTestDB()
})
Describe("Search2", func() {
It("finds artists by name", func() {
r := newReq("search2", "query", "Beatles")
resp, err := router.Search2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.SearchResult2).ToNot(BeNil())
Expect(resp.SearchResult2.Artist).ToNot(BeEmpty())
found := false
for _, a := range resp.SearchResult2.Artist {
if a.Name == "The Beatles" {
found = true
break
}
}
Expect(found).To(BeTrue(), "expected to find artist 'The Beatles'")
})
It("finds albums by name", func() {
r := newReq("search2", "query", "Abbey Road")
resp, err := router.Search2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SearchResult2).ToNot(BeNil())
Expect(resp.SearchResult2.Album).ToNot(BeEmpty())
found := false
for _, a := range resp.SearchResult2.Album {
if a.Title == "Abbey Road" {
found = true
break
}
}
Expect(found).To(BeTrue(), "expected to find album 'Abbey Road'")
})
It("finds songs by title", func() {
r := newReq("search2", "query", "Come Together")
resp, err := router.Search2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SearchResult2).ToNot(BeNil())
Expect(resp.SearchResult2.Song).ToNot(BeEmpty())
found := false
for _, s := range resp.SearchResult2.Song {
if s.Title == "Come Together" {
found = true
break
}
}
Expect(found).To(BeTrue(), "expected to find song 'Come Together'")
})
It("respects artistCount/albumCount/songCount limits", func() {
r := newReq("search2", "query", "Beatles",
"artistCount", "1", "albumCount", "1", "songCount", "1")
resp, err := router.Search2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SearchResult2).ToNot(BeNil())
Expect(len(resp.SearchResult2.Artist)).To(BeNumerically("<=", 1))
Expect(len(resp.SearchResult2.Album)).To(BeNumerically("<=", 1))
Expect(len(resp.SearchResult2.Song)).To(BeNumerically("<=", 1))
})
It("supports offset parameters", func() {
// First get all results for Beatles
r1 := newReq("search2", "query", "Beatles", "songCount", "500")
resp1, err := router.Search2(r1)
Expect(err).ToNot(HaveOccurred())
allSongs := resp1.SearchResult2.Song
if len(allSongs) > 1 {
// Get with offset to skip the first song
r2 := newReq("search2", "query", "Beatles", "songOffset", "1", "songCount", "500")
resp2, err := router.Search2(r2)
Expect(err).ToNot(HaveOccurred())
Expect(resp2.SearchResult2).ToNot(BeNil())
Expect(len(resp2.SearchResult2.Song)).To(Equal(len(allSongs) - 1))
}
})
It("returns empty results for non-matching query", func() {
r := newReq("search2", "query", "ZZZZNONEXISTENT99999")
resp, err := router.Search2(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SearchResult2).ToNot(BeNil())
Expect(resp.SearchResult2.Artist).To(BeEmpty())
Expect(resp.SearchResult2.Album).To(BeEmpty())
Expect(resp.SearchResult2.Song).To(BeEmpty())
})
})
Describe("Search3", func() {
It("returns results in ID3 format", func() {
r := newReq("search3", "query", "Beatles")
resp, err := router.Search3(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.SearchResult3).ToNot(BeNil())
// Verify ID3 format: Artist should be ArtistID3 with Name and AlbumCount
Expect(resp.SearchResult3.Artist).ToNot(BeEmpty())
Expect(resp.SearchResult3.Artist[0].Name).ToNot(BeEmpty())
Expect(resp.SearchResult3.Artist[0].Id).ToNot(BeEmpty())
})
It("finds across all entity types simultaneously", func() {
// "Beatles" should match artist, albums, and songs by The Beatles
r := newReq("search3", "query", "Beatles")
resp, err := router.Search3(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.SearchResult3).ToNot(BeNil())
// Should find at least the artist "The Beatles"
artistFound := false
for _, a := range resp.SearchResult3.Artist {
if a.Name == "The Beatles" {
artistFound = true
break
}
}
Expect(artistFound).To(BeTrue(), "expected to find artist 'The Beatles'")
// Should find albums by The Beatles (albums contain "Beatles" in artist field)
// Albums are returned as AlbumID3 type
for _, a := range resp.SearchResult3.Album {
Expect(a.Id).ToNot(BeEmpty())
Expect(a.Name).ToNot(BeEmpty())
}
// Songs are returned as Child type
for _, s := range resp.SearchResult3.Song {
Expect(s.Id).ToNot(BeEmpty())
Expect(s.Title).ToNot(BeEmpty())
}
})
})
})

View File

@@ -0,0 +1,143 @@
package e2e
import (
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Sharing Endpoints", Ordered, func() {
var shareID string
var albumID string
var songID string
BeforeAll(func() {
setupTestDB()
conf.Server.EnableSharing = true
albums, err := ds.Album(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"album.name": "Abbey Road"},
})
Expect(err).ToNot(HaveOccurred())
Expect(albums).ToNot(BeEmpty())
albumID = albums[0].ID
songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.Eq{"title": "Come Together"},
})
Expect(err).ToNot(HaveOccurred())
Expect(songs).ToNot(BeEmpty())
songID = songs[0].ID
})
It("getShares returns empty initially", func() {
r := newReq("getShares")
resp, err := router.GetShares(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Shares).ToNot(BeNil())
Expect(resp.Shares.Share).To(BeEmpty())
})
It("createShare creates a share for an album", func() {
r := newReq("createShare", "id", albumID, "description", "Check out this album")
resp, err := router.CreateShare(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Shares).ToNot(BeNil())
Expect(resp.Shares.Share).To(HaveLen(1))
share := resp.Shares.Share[0]
Expect(share.ID).ToNot(BeEmpty())
Expect(share.Description).To(Equal("Check out this album"))
Expect(share.Username).To(Equal(adminUser.UserName))
shareID = share.ID
})
It("getShares returns the created share", func() {
r := newReq("getShares")
resp, err := router.GetShares(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Shares).ToNot(BeNil())
Expect(resp.Shares.Share).To(HaveLen(1))
share := resp.Shares.Share[0]
Expect(share.ID).To(Equal(shareID))
Expect(share.Description).To(Equal("Check out this album"))
Expect(share.Username).To(Equal(adminUser.UserName))
Expect(share.Entry).ToNot(BeEmpty())
})
It("updateShare modifies the description", func() {
r := newReq("updateShare", "id", shareID, "description", "Updated description")
resp, err := router.UpdateShare(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
// Verify update
r = newReq("getShares")
resp, err = router.GetShares(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Shares.Share).To(HaveLen(1))
Expect(resp.Shares.Share[0].Description).To(Equal("Updated description"))
})
It("deleteShare removes it", func() {
r := newReq("deleteShare", "id", shareID)
resp, err := router.DeleteShare(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
It("getShares returns empty after deletion", func() {
r := newReq("getShares")
resp, err := router.GetShares(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Shares).ToNot(BeNil())
Expect(resp.Shares.Share).To(BeEmpty())
})
It("createShare works with a song ID", func() {
r := newReq("createShare", "id", songID, "description", "Great song")
resp, err := router.CreateShare(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Shares).ToNot(BeNil())
Expect(resp.Shares.Share).To(HaveLen(1))
Expect(resp.Shares.Share[0].Description).To(Equal("Great song"))
Expect(resp.Shares.Share[0].Entry).To(HaveLen(1))
})
It("createShare returns error when id parameter is missing", func() {
r := newReq("createShare")
_, err := router.CreateShare(r)
Expect(err).To(HaveOccurred())
})
It("updateShare returns error when id parameter is missing", func() {
r := newReq("updateShare")
_, err := router.UpdateShare(r)
Expect(err).To(HaveOccurred())
})
It("deleteShare returns error when id parameter is missing", func() {
r := newReq("deleteShare")
_, err := router.DeleteShare(r)
Expect(err).To(HaveOccurred())
})
})

View File

@@ -0,0 +1,86 @@
package e2e
import (
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("System Endpoints", func() {
BeforeEach(func() {
setupTestDB()
})
Describe("ping", func() {
It("returns a successful response", func() {
r := newReq("ping")
resp, err := router.Ping(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
})
})
Describe("getLicense", func() {
It("returns a valid license", func() {
r := newReq("getLicense")
resp, err := router.GetLicense(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.License).ToNot(BeNil())
Expect(resp.License.Valid).To(BeTrue())
})
})
Describe("getOpenSubsonicExtensions", func() {
It("returns a list of supported extensions", func() {
r := newReq("getOpenSubsonicExtensions")
resp, err := router.GetOpenSubsonicExtensions(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.OpenSubsonicExtensions).ToNot(BeNil())
Expect(*resp.OpenSubsonicExtensions).ToNot(BeEmpty())
})
It("includes the transcodeOffset extension", func() {
r := newReq("getOpenSubsonicExtensions")
resp, err := router.GetOpenSubsonicExtensions(r)
Expect(err).ToNot(HaveOccurred())
extensions := *resp.OpenSubsonicExtensions
var names []string
for _, ext := range extensions {
names = append(names, ext.Name)
}
Expect(names).To(ContainElement("transcodeOffset"))
})
It("includes the formPost extension", func() {
r := newReq("getOpenSubsonicExtensions")
resp, err := router.GetOpenSubsonicExtensions(r)
Expect(err).ToNot(HaveOccurred())
extensions := *resp.OpenSubsonicExtensions
var names []string
for _, ext := range extensions {
names = append(names, ext.Name)
}
Expect(names).To(ContainElement("formPost"))
})
It("includes the songLyrics extension", func() {
r := newReq("getOpenSubsonicExtensions")
resp, err := router.GetOpenSubsonicExtensions(r)
Expect(err).ToNot(HaveOccurred())
extensions := *resp.OpenSubsonicExtensions
var names []string
for _, ext := range extensions {
names = append(names, ext.Name)
}
Expect(names).To(ContainElement("songLyrics"))
})
})
})

View File

@@ -0,0 +1,56 @@
package e2e
import (
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("User Endpoints", func() {
BeforeEach(func() {
setupTestDB()
})
It("getUser returns current user info", func() {
r := newReq("getUser", "username", adminUser.UserName)
resp, err := router.GetUser(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.User).ToNot(BeNil())
Expect(resp.User.Username).To(Equal(adminUser.UserName))
Expect(resp.User.AdminRole).To(BeTrue())
Expect(resp.User.StreamRole).To(BeTrue())
Expect(resp.User.Folder).ToNot(BeEmpty())
})
It("getUser with matching username case-insensitive succeeds", func() {
r := newReq("getUser", "username", "Admin")
resp, err := router.GetUser(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.User).ToNot(BeNil())
Expect(resp.User.Username).To(Equal(adminUser.UserName))
})
It("getUser with different username returns authorization error", func() {
r := newReq("getUser", "username", "otheruser")
_, err := router.GetUser(r)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("not authorized"))
})
It("getUsers returns list with current user only", func() {
r := newReq("getUsers")
resp, err := router.GetUsers(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Status).To(Equal(responses.StatusOK))
Expect(resp.Users).ToNot(BeNil())
Expect(resp.Users.User).To(HaveLen(1))
Expect(resp.Users.User[0].Username).To(Equal(adminUser.UserName))
Expect(resp.Users.User[0].AdminRole).To(BeTrue())
})
})

View File

@@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/req"
"github.com/navidrome/navidrome/utils/slice"
)
@@ -162,7 +163,11 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response
pls.Duration = int32(p.Duration)
pls.Created = p.CreatedAt
if p.IsSmartPlaylist() {
pls.Changed = time.Now()
if p.EvaluatedAt != nil {
pls.Changed = *p.EvaluatedAt
} else {
pls.Changed = time.Now()
}
} else {
pls.Changed = p.UpdatedAt
}
@@ -176,6 +181,24 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response
pls.Owner = p.OwnerName
pls.Public = p.Public
pls.CoverArt = p.CoverArtID().String()
pls.OpenSubsonicPlaylist = buildOSPlaylist(ctx, p)
return pls
}
func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubsonicPlaylist {
pls := responses.OpenSubsonicPlaylist{}
if p.IsSmartPlaylist() {
pls.Readonly = true
if p.EvaluatedAt != nil {
pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay))
}
} else {
user, ok := request.UserFrom(ctx)
pls.Readonly = !ok || p.OwnerID != user.ID
}
return &pls
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@@ -25,96 +26,194 @@ var _ = Describe("buildPlaylist", func() {
ds = &tests.MockDataStore{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
ctx = context.Background()
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
playlist = model.Playlist{
ID: "pls-1",
Name: "My Playlist",
Comment: "Test comment",
OwnerName: "admin",
Public: true,
SongCount: 10,
Duration: 600,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
})
Context("with minimal client", func() {
Describe("normal playlist", func() {
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = "minimal-client"
player := model.Player{Client: "minimal-client"}
ctx = request.WithPlayer(ctx, player)
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
playlist = model.Playlist{
ID: "pls-1",
Name: "My Playlist",
Comment: "Test comment",
OwnerName: "admin",
OwnerID: "1234",
Public: true,
SongCount: 10,
Duration: 600,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
})
It("returns only basic fields", func() {
result := router.buildPlaylist(ctx, playlist)
Context("with minimal client", func() {
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = "minimal-client"
player := model.Player{Client: "minimal-client"}
ctx = request.WithPlayer(ctx, player)
})
Expect(result.Id).To(Equal("pls-1"))
Expect(result.Name).To(Equal("My Playlist"))
Expect(result.SongCount).To(Equal(int32(10)))
Expect(result.Duration).To(Equal(int32(600)))
Expect(result.Created).To(Equal(playlist.CreatedAt))
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
It("returns only basic fields", func() {
result := router.buildPlaylist(ctx, playlist)
// These should not be set
Expect(result.Comment).To(BeEmpty())
Expect(result.Owner).To(BeEmpty())
Expect(result.Public).To(BeFalse())
Expect(result.CoverArt).To(BeEmpty())
Expect(result.Id).To(Equal("pls-1"))
Expect(result.Name).To(Equal("My Playlist"))
Expect(result.SongCount).To(Equal(int32(10)))
Expect(result.Duration).To(Equal(int32(600)))
Expect(result.Created).To(Equal(playlist.CreatedAt))
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
// These should not be set
Expect(result.Comment).To(BeEmpty())
Expect(result.Owner).To(BeEmpty())
Expect(result.Public).To(BeFalse())
Expect(result.CoverArt).To(BeEmpty())
})
})
Context("with non-minimal client", func() {
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = "minimal-client"
player := model.Player{Client: "regular-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns all fields", func() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Id).To(Equal("pls-1"))
Expect(result.Name).To(Equal("My Playlist"))
Expect(result.SongCount).To(Equal(int32(10)))
Expect(result.Duration).To(Equal(int32(600)))
Expect(result.Created).To(Equal(playlist.CreatedAt))
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
Expect(result.Readonly).To(BeTrue())
})
It("returns all fields when as owner", func() {
ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"})
result := router.buildPlaylist(ctx, playlist)
Expect(result.Id).To(Equal("pls-1"))
Expect(result.Name).To(Equal("My Playlist"))
Expect(result.SongCount).To(Equal(int32(10)))
Expect(result.Duration).To(Equal(int32(600)))
Expect(result.Created).To(Equal(playlist.CreatedAt))
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
Expect(result.Readonly).To(BeFalse())
})
})
Context("when minimal clients list is empty", func() {
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = ""
player := model.Player{Client: "any-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns all fields", func() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
})
})
Context("when no player in context", func() {
It("returns all fields", func() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
})
})
})
Context("with non-minimal client", func() {
Describe("smart playlist", func() {
evaluatedAt := time.Date(2023, 2, 20, 15, 45, 0, 0, time.UTC)
validUntil := evaluatedAt.Add(5 * time.Second)
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = "minimal-client"
player := model.Player{Client: "regular-client"}
ctx = request.WithPlayer(ctx, player)
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
playlist = model.Playlist{
ID: "pls-1",
Name: "My Playlist",
Comment: "Test comment",
OwnerName: "admin",
OwnerID: "1234",
Public: true,
SongCount: 10,
Duration: 600,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
EvaluatedAt: &evaluatedAt,
Rules: &criteria.Criteria{
Expression: criteria.All{criteria.Contains{"title": "title"}},
},
}
})
It("returns all fields", func() {
result := router.buildPlaylist(ctx, playlist)
Context("with minimal client", func() {
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = "minimal-client"
player := model.Player{Client: "minimal-client"}
ctx = request.WithPlayer(ctx, player)
})
Expect(result.Id).To(Equal("pls-1"))
Expect(result.Name).To(Equal("My Playlist"))
Expect(result.SongCount).To(Equal(int32(10)))
Expect(result.Duration).To(Equal(int32(600)))
Expect(result.Created).To(Equal(playlist.CreatedAt))
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
It("returns only basic fields", func() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Id).To(Equal("pls-1"))
Expect(result.Name).To(Equal("My Playlist"))
Expect(result.SongCount).To(Equal(int32(10)))
Expect(result.Duration).To(Equal(int32(600)))
Expect(result.Created).To(Equal(playlist.CreatedAt))
Expect(result.Changed).To(Equal(evaluatedAt))
// These should not be set
Expect(result.Comment).To(BeEmpty())
Expect(result.Owner).To(BeEmpty())
Expect(result.Public).To(BeFalse())
Expect(result.CoverArt).To(BeEmpty())
Expect(result.OpenSubsonicPlaylist).To(BeNil())
})
})
Context("with non-minimal client", func() {
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = "minimal-client"
player := model.Player{Client: "regular-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns all fields", func() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Id).To(Equal("pls-1"))
Expect(result.Name).To(Equal("My Playlist"))
Expect(result.SongCount).To(Equal(int32(10)))
Expect(result.Duration).To(Equal(int32(600)))
Expect(result.Created).To(Equal(playlist.CreatedAt))
Expect(result.Changed).To(Equal(*playlist.EvaluatedAt))
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
Expect(result.Readonly).To(BeTrue())
Expect(result.ValidUntil).To(Equal(&validUntil))
})
})
})
Context("when minimal clients list is empty", func() {
BeforeEach(func() {
conf.Server.Subsonic.MinimalClients = ""
player := model.Player{Client: "any-client"}
ctx = request.WithPlayer(ctx, player)
})
It("returns all fields", func() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
})
})
Context("when no player in context", func() {
It("returns all fields", func() {
result := router.buildPlaylist(ctx, playlist)
Expect(result.Comment).To(Equal("Test comment"))
Expect(result.Owner).To(Equal("admin"))
Expect(result.Public).To(BeTrue())
})
})
})
var _ = Describe("UpdatePlaylist", func() {

View File

@@ -14,9 +14,20 @@
"duration": 120,
"public": true,
"owner": "admin",
"created": "2023-02-20T14:45:00Z",
"changed": "2023-02-20T14:45:00Z",
"coverArt": "pl-123123123123",
"readonly": true,
"validUntil": "2023-02-20T14:45:00Z"
},
{
"id": "333",
"name": "ccc",
"songCount": 0,
"duration": 0,
"created": "0001-01-01T00:00:00Z",
"changed": "0001-01-01T00:00:00Z",
"coverArt": "pl-123123123123"
"readonly": false
},
{
"id": "222",

View File

@@ -1,6 +1,7 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<playlists>
<playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z" coverArt="pl-123123123123"></playlist>
<playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="2023-02-20T14:45:00Z" changed="2023-02-20T14:45:00Z" coverArt="pl-123123123123" readonly="true" validUntil="2023-02-20T14:45:00Z"></playlist>
<playlist id="333" name="ccc" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
<playlist id="222" name="bbb" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
</playlists>
</subsonic-response>

View File

@@ -298,16 +298,17 @@ type AlbumList2 struct {
}
type Playlist struct {
Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
SongCount int32 `xml:"songCount,attr" json:"songCount"`
Duration int32 `xml:"duration,attr" json:"duration"`
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
Created time.Time `xml:"created,attr" json:"created"`
Changed time.Time `xml:"changed,attr" json:"changed"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
Id string `xml:"id,attr" json:"id"`
Name string `xml:"name,attr" json:"name"`
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
SongCount int32 `xml:"songCount,attr" json:"songCount"`
Duration int32 `xml:"duration,attr" json:"duration"`
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
Created time.Time `xml:"created,attr" json:"created"`
Changed time.Time `xml:"changed,attr" json:"changed"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
*OpenSubsonicPlaylist `xml:",omitempty" json:",omitempty"`
/*
<xs:sequence>
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
@@ -315,6 +316,11 @@ type Playlist struct {
*/
}
type OpenSubsonicPlaylist struct {
Readonly bool `xml:"readonly,attr,omitempty" json:"readonly"`
ValidUntil *time.Time `xml:"validUntil,attr,omitempty" json:"validUntil,omitempty"`
}
type Playlists struct {
Playlist []Playlist `xml:"playlist" json:"playlist,omitempty"`
}

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/server/subsonic/responses"
. "github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
@@ -531,9 +532,9 @@ var _ = Describe("Responses", func() {
})
Context("with data", func() {
timestamp, _ := time.Parse(time.RFC3339, "2020-04-11T16:43:00Z04:00")
timestamp := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
BeforeEach(func() {
pls := make([]Playlist, 2)
pls := make([]Playlist, 3)
pls[0] = Playlist{
Id: "111",
Name: "aaa",
@@ -545,8 +546,13 @@ var _ = Describe("Responses", func() {
CoverArt: "pl-123123123123",
Created: timestamp,
Changed: timestamp,
OpenSubsonicPlaylist: &responses.OpenSubsonicPlaylist{
Readonly: true,
ValidUntil: &timestamp,
},
}
pls[1] = Playlist{Id: "222", Name: "bbb"}
pls[1] = Playlist{Id: "333", Name: "ccc", OpenSubsonicPlaylist: &responses.OpenSubsonicPlaylist{}}
pls[2] = Playlist{Id: "222", Name: "bbb"}
response.Playlists.Playlist = pls
})

View File

@@ -0,0 +1,19 @@
[
{
"area": "Japan",
"artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
"begin_year": 2012,
"mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
"name": "Mili",
"rels": {
"free streaming": "https://www.deezer.com/artist/56563392",
"official homepage": "http://projectmili.com/",
"purchase for download": "https://recochoku.jp/artist/2000285803/",
"social network": "https://www.instagram.com/projectmili/",
"streaming": "https://tidal.com/artist/3848902",
"wikidata": "https://www.wikidata.org/wiki/Q27309228",
"youtube": "https://www.youtube.com/channel/UCVh47EKH9VLresRqiYi9txw"
},
"type": "Group"
}
]

View File

@@ -0,0 +1,15 @@
[
{
"area": "Japan",
"artist_mbid": "7c2cc610-f998-43ef-a08f-dae3344b8973",
"mbid": "7c2cc610-f998-43ef-a08f-dae3344b8973",
"name": "Feryquitous",
"rels": {
"free streaming": "https://www.deezer.com/artist/9841008",
"purchase for download": "https://itunes.apple.com/jp/artist/id1083544578",
"social network": "https://twitter.com/Feryquitous_",
"youtube": "https://www.youtube.com/channel/UCj2nw_9puY3sJoDbkE-FCQA"
},
"type": "Person"
}
]

View File

@@ -0,0 +1 @@
[{"artist_mbid": "f27ec8db-af05-4f36-916e-3d57f91ecf5e", "name": "Michael Jackson", "comment": "\u201cKing of Pop\u201d", "type": "Person", "gender": "Male", "score": 800, "reference_mbid": "db92a151-1ac2-438b-bc43-b82e149ddd50"}, {"artist_mbid": "7364dea6-ca9a-48e3-be01-b44ad0d19897", "name": "a-ha", "comment": "Norwegian synth\u2010pop band", "type": "Group", "gender": null, "score": 792, "reference_mbid": "db92a151-1ac2-438b-bc43-b82e149ddd50"}]

View File

@@ -0,0 +1 @@
[{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"aha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"aha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"80033c72-aa19-4ba8-9227-afb075fec46e","recording_name":"Wake Me Up Before You GoGo","artist_credit_name":"Wham!","artist_credit_mbids":null,"release_name":"Make It Big","release_mbid":"c143d542-48dc-446b-b523-1762da721638","caa_id":2622532701,"caa_release_mbid":"ec01ad0c-a28f-4d45-bed7-d73014161c38","score":65,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"ef4c6855-949e-4e22-b41e-8e0a2d372d5f","recording_name":"Tainted Love","artist_credit_name":"Soft Cell","artist_credit_mbids":null,"release_name":"Non-Stop Erotic Cabaret","release_mbid":"1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1","caa_id":1031647403,"caa_release_mbid":"c3367d3a-2f6c-48d1-95c5-c1ee7a49c479","score":61,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5","recording_name":"Everybody Wants to Rule the World","artist_credit_name":"Tears for Fears","artist_credit_mbids":null,"release_name":"Songs From the Big Chair","release_mbid":"21f19b06-81f1-347a-add5-5d0c77696597","caa_id":19682986993,"caa_release_mbid":"9aefc6dd-216a-4271-ada1-d9cf67956f39","score":68,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"}]

View File

@@ -0,0 +1 @@
[{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"aha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"80033c72-aa19-4ba8-9227-afb075fec46e","recording_name":"Wake Me Up Before You GoGo","artist_credit_name":"Wham!","artist_credit_mbids":null,"release_name":"Make It Big","release_mbid":"c143d542-48dc-446b-b523-1762da721638","caa_id":2622532701,"caa_release_mbid":"ec01ad0c-a28f-4d45-bed7-d73014161c38","score":65,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"}]

View File

@@ -0,0 +1,81 @@
[
{
"artist_mbids": ["d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"],
"artist_name": "Mili",
"artists": [
{
"artist_credit_name": "Mili",
"artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
"join_phrase": ""
}
],
"caa_id": 14987576054,
"caa_release_mbid": "38a8f6e1-0e34-4418-a89d-78240a367408",
"length": 211912,
"recording_mbid": "9980309d-3480-4e7e-89ce-fce971a452be",
"recording_name": "world.execute(me);",
"release_color": { "blue": 109, "green": 94, "red": 95 },
"release_mbid": "38a8f6e1-0e34-4418-a89d-78240a367408",
"release_name": "Miracle Milk",
"tags": [
{
"count": 1,
"genre_mbid": "911c7bbb-172d-4df8-9478-dbff4296e791",
"tag": "pop"
},
{
"count": 1,
"genre_mbid": "b739a895-85ed-4ad3-8717-4e9ef5387dd8",
"tag": "dance-pop"
},
{
"count": 1,
"genre_mbid": "9c8ba153-740e-4b88-b7ff-31d004944c95",
"tag": "nerdcore"
},
{
"count": 1,
"genre_mbid": "c4a69842-f891-4569-9506-1882aa5db433",
"tag": "electronic rock"
},
{ "count": 1, "tag": "hackercore" },
{ "count": 1, "tag": "meter:4/4" },
{ "count": 1, "tag": "vocal:true" },
{ "count": 1, "tag": "bpm:130" },
{
"count": 1,
"genre_mbid": "e5bba957-8c91-496a-a675-c6d0c6b51c33",
"tag": "dance"
},
{
"count": 1,
"genre_mbid": "89255676-1f14-4dd8-bbad-fca839d6aff4",
"tag": "electronic"
}
],
"total_listen_count": 19440,
"total_user_count": 1102
},
{
"artist_mbids": ["d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"],
"artist_name": "Mili",
"artists": [
{
"artist_credit_name": "Mili",
"artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
"join_phrase": ""
}
],
"caa_id": 31388973421,
"caa_release_mbid": "e58ed9ef-2bc1-4480-9d6d-2d799beb5ba9",
"length": 174000,
"recording_mbid": "afa2c83d-b17f-4029-b9da-790ea9250cf9",
"recording_name": "String Theocracy",
"release_color": { "blue": 92, "green": 147, "red": 164 },
"release_mbid": "d79a38e3-7016-4f39-a31a-f495ce914b8e",
"release_name": "String Theocracy",
"tags": [],
"total_listen_count": 8986,
"total_user_count": 712
}
]