mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-08 14:01:10 -05:00
Compare commits
2 Commits
subsonic-e
...
os-fix-scr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e93ebfc73 | ||
|
|
80e9921d45 |
@@ -15,5 +15,4 @@ dist
|
||||
binaries
|
||||
cache
|
||||
music
|
||||
music.old
|
||||
!Dockerfile
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,7 +20,6 @@ cache/*
|
||||
coverage.out
|
||||
dist
|
||||
music
|
||||
music.old
|
||||
*.db*
|
||||
.gitinfo
|
||||
docker-compose.yml
|
||||
|
||||
@@ -252,7 +252,7 @@ var _ = Describe("JWT Authentication", func() {
|
||||
|
||||
// Writer goroutine
|
||||
wg.Go(func() {
|
||||
for i := range 100 {
|
||||
for i := 0; i < 100; i++ {
|
||||
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
@@ -260,7 +260,7 @@ var _ = Describe("JWT Authentication", func() {
|
||||
|
||||
// Reader goroutine
|
||||
wg.Go(func() {
|
||||
for range 100 {
|
||||
for i := 0; i < 100; i++ {
|
||||
cache.get()
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ 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()
|
||||
@@ -255,7 +254,7 @@ func parseTIPL(tags map[string][]string) {
|
||||
}
|
||||
var currentRole string
|
||||
var currentValue []string
|
||||
for part := range strings.SplitSeq(tipl[0], " ") {
|
||||
for _, part := range strings.Split(tipl[0], " ") {
|
||||
if _, ok := tiplMapping[part]; ok {
|
||||
addRole(currentRole, currentValue)
|
||||
currentRole = part
|
||||
|
||||
@@ -31,12 +31,6 @@ 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
|
||||
@@ -101,7 +95,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 = cleanContent(a.Description.Summary)
|
||||
resp.Description = strings.TrimSpace(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)
|
||||
@@ -177,7 +171,7 @@ func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid str
|
||||
return "", err
|
||||
}
|
||||
if isValidContent(a.Bio.Summary) {
|
||||
return cleanContent(a.Bio.Summary), nil
|
||||
return strings.TrimSpace(a.Bio.Summary), nil
|
||||
}
|
||||
log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
|
||||
}
|
||||
|
||||
@@ -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."))
|
||||
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(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",
|
||||
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>.",
|
||||
URL: "https://www.last.fm/music/Cher/Believe",
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
|
||||
@@ -65,7 +65,7 @@ func (s *Router) routes() http.Handler {
|
||||
}
|
||||
|
||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]any{
|
||||
resp := map[string]interface{}{
|
||||
"apiKey": s.apiKey,
|
||||
}
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
|
||||
@@ -118,129 +118,12 @@ 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 {
|
||||
// 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
|
||||
return listenBrainzConstructor(ds)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
_ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil)
|
||||
_ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil)
|
||||
_ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil)
|
||||
_ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil)
|
||||
)
|
||||
|
||||
@@ -4,14 +4,11 @@ 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"
|
||||
@@ -165,279 +162,4 @@ 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: "a‐ha",
|
||||
ArtistMBID: "",
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
},
|
||||
{
|
||||
ID: "",
|
||||
Name: "Wake Me Up Before You Go‐Go",
|
||||
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: "a‐ha",
|
||||
ArtistMBID: "",
|
||||
Album: "Hunting High and Low",
|
||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Duration: 0,
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *Router) routes() http.Handler {
|
||||
}
|
||||
|
||||
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]any{}
|
||||
resp := map[string]interface{}{}
|
||||
u, _ := request.UserFrom(r.Context())
|
||||
key, err := s.sessionKeys.Get(r.Context(), u.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
@@ -107,7 +107,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"status": resp.Valid, "user": resp.UserName})
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName})
|
||||
}
|
||||
|
||||
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -37,7 +37,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
|
||||
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
|
||||
r.getLinkStatus(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]any
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(false))
|
||||
})
|
||||
@@ -47,7 +47,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
|
||||
req = httptest.NewRequest("GET", "/listenbrainz/link", nil)
|
||||
r.getLinkStatus(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]any
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(true))
|
||||
})
|
||||
@@ -80,7 +80,7 @@ var _ = Describe("ListenBrainz Auth Router", func() {
|
||||
req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`))
|
||||
r.link(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]any
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["status"]).To(Equal(true))
|
||||
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))
|
||||
|
||||
@@ -2,29 +2,16 @@ 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
|
||||
@@ -75,14 +62,14 @@ const (
|
||||
|
||||
type listenInfo struct {
|
||||
ListenedAt int `json:"listened_at,omitempty"`
|
||||
TrackMetadata trackMetadata `json:"track_metadata"`
|
||||
TrackMetadata trackMetadata `json:"track_metadata,omitempty"`
|
||||
}
|
||||
|
||||
type trackMetadata struct {
|
||||
ArtistName string `json:"artist_name,omitempty"`
|
||||
TrackName string `json:"track_name,omitempty"`
|
||||
ReleaseName string `json:"release_name,omitempty"`
|
||||
AdditionalInfo additionalInfo `json:"additional_info"`
|
||||
AdditionalInfo additionalInfo `json:"additional_info,omitempty"`
|
||||
}
|
||||
|
||||
type additionalInfo struct {
|
||||
@@ -101,7 +88,7 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain
|
||||
r := &listenBrainzRequest{
|
||||
ApiKey: apiKey,
|
||||
}
|
||||
response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r)
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -117,7 +104,7 @@ func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenI
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
|
||||
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -135,7 +122,7 @@ func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) err
|
||||
Payload: []listenInfo{li},
|
||||
},
|
||||
}
|
||||
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
|
||||
resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -154,7 +141,7 @@ func (c *client) path(endpoint string) (string, error) {
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
|
||||
func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
|
||||
b, _ := json.Marshal(r.Body)
|
||||
uri, err := c.path(endpoint)
|
||||
if err != nil {
|
||||
@@ -190,189 +177,3 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,13 +4,10 @@ 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"
|
||||
@@ -120,345 +117,4 @@ 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: "a‐ha",
|
||||
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 Go‐Go",
|
||||
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: "a‐ha",
|
||||
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: "a‐ha",
|
||||
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 Go‐Go",
|
||||
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: "a‐ha",
|
||||
ReleaseName: "Hunting High and Low",
|
||||
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||
Score: 124,
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,7 +73,7 @@ func (c *client) authorize(ctx context.Context) (string, error) {
|
||||
auth := c.id + ":" + c.secret
|
||||
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
|
||||
|
||||
response := map[string]any{}
|
||||
response := map[string]interface{}{}
|
||||
err := c.makeRequest(req, &response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -86,7 +86,7 @@ func (c *client) authorize(ctx context.Context) (string, error) {
|
||||
return "", errors.New("invalid response")
|
||||
}
|
||||
|
||||
func (c *client) makeRequest(req *http.Request, response any) error {
|
||||
func (c *client) makeRequest(req *http.Request, response interface{}) error {
|
||||
log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -195,10 +194,8 @@ type deezerOptions struct {
|
||||
}
|
||||
|
||||
type listenBrainzOptions struct {
|
||||
Enabled bool
|
||||
BaseURL string
|
||||
ArtistAlgorithm string
|
||||
TrackAlgorithm string
|
||||
Enabled bool
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type httpHeaderOptions struct {
|
||||
@@ -434,7 +431,7 @@ func mapDeprecatedOption(legacyName, newName string) {
|
||||
func parseIniFileConfiguration() {
|
||||
cfgFile := viper.ConfigFileUsed()
|
||||
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
|
||||
var iniConfig map[string]any
|
||||
var iniConfig map[string]interface{}
|
||||
err := viper.Unmarshal(&iniConfig)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||
@@ -467,7 +464,7 @@ func disableExternalServices() {
|
||||
}
|
||||
|
||||
func validatePlaylistsPath() error {
|
||||
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
for _, path := range strings.Split(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
_, err := doublestar.Match(path, "")
|
||||
if err != nil {
|
||||
log.Error("Invalid PlaylistsPath", "path", path, err)
|
||||
@@ -481,7 +478,7 @@ func validatePlaylistsPath() error {
|
||||
// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned.
|
||||
func parseLanguages(lang string) []string {
|
||||
var languages []string
|
||||
for l := range strings.SplitSeq(lang, ",") {
|
||||
for _, l := range strings.Split(lang, ",") {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
languages = append(languages, l)
|
||||
@@ -495,7 +492,13 @@ func parseLanguages(lang string) []string {
|
||||
|
||||
func validatePurgeMissingOption() error {
|
||||
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
||||
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
|
||||
valid := false
|
||||
for _, v := range allowedValues {
|
||||
if v == Server.Scanner.PurgeMissing {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
||||
log.Error(err.Error())
|
||||
@@ -653,9 +656,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("deezer.enabled", true)
|
||||
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
|
||||
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
|
||||
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
viper.SetDefault("enablescrobblehistory", true)
|
||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||
viper.SetDefault("backup.path", "")
|
||||
|
||||
@@ -74,10 +74,6 @@ 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')
|
||||
|
||||
@@ -365,7 +365,7 @@ var _ = Describe("Agents", func() {
|
||||
})
|
||||
|
||||
type mockAgent struct {
|
||||
Args []any
|
||||
Args []interface{}
|
||||
Err error
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ func (a *mockAgent) AgentName() string {
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) {
|
||||
a.Args = []any{id, name}
|
||||
a.Args = []interface{}{id, name}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (st
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) {
|
||||
a.Args = []any{id, name, mbid}
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
@@ -390,7 +390,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
|
||||
a.Args = []any{id, name, mbid}
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
@@ -398,7 +398,7 @@ func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string)
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
a.Args = []any{id, name, mbid}
|
||||
a.Args = []interface{}{id, name, mbid}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -409,7 +409,7 @@ func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
||||
a.Args = []any{id, name, mbid, limit}
|
||||
a.Args = []interface{}{id, name, mbid, limit}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -420,7 +420,7 @@ func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string,
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []any{id, artistName, mbid, count}
|
||||
a.Args = []interface{}{id, artistName, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -431,7 +431,7 @@ func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid st
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
|
||||
a.Args = []any{name, artist, mbid}
|
||||
a.Args = []interface{}{name, artist, mbid}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -444,7 +444,7 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []any{id, name, artist, mbid, count}
|
||||
a.Args = []interface{}{id, name, artist, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -455,7 +455,7 @@ func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist,
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []any{id, name, artist, mbid, count}
|
||||
a.Args = []interface{}{id, name, artist, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -466,7 +466,7 @@ func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist,
|
||||
}
|
||||
|
||||
func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) {
|
||||
a.Args = []any{id, name, mbid, count}
|
||||
a.Args = []interface{}{id, name, mbid, count}
|
||||
if a.Err != nil {
|
||||
return nil, a.Err
|
||||
}
|
||||
@@ -488,12 +488,12 @@ type testImageAgent struct {
|
||||
Name string
|
||||
Images []ExternalImage
|
||||
Err error
|
||||
Args []any
|
||||
Args []interface{}
|
||||
}
|
||||
|
||||
func (t *testImageAgent) AgentName() string { return t.Name }
|
||||
|
||||
func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
t.Args = []any{id, name, mbid}
|
||||
t.Args = []interface{}{id, name, mbid}
|
||||
return t.Images, t.Err
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ var _ = Describe("CacheWarmer", func() {
|
||||
|
||||
It("processes items in batches", func() {
|
||||
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
|
||||
for i := range 5 {
|
||||
for i := 0; i < 5; i++ {
|
||||
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string,
|
||||
|
||||
func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "embedded":
|
||||
|
||||
@@ -99,7 +99,7 @@ func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error
|
||||
|
||||
func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc {
|
||||
var ff []sourceFunc
|
||||
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
|
||||
for _, pattern := range strings.Split(strings.ToLower(priority), ",") {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
switch {
|
||||
case pattern == "external":
|
||||
@@ -116,7 +116,7 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin
|
||||
func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc {
|
||||
return func() (io.ReadCloser, string, error) {
|
||||
current := artistFolder
|
||||
for range maxArtistFolderTraversalDepth {
|
||||
for i := 0; i < maxArtistFolderTraversalDepth; i++ {
|
||||
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
|
||||
return reader, path, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -54,7 +53,9 @@ func createBaseClaims() map[string]any {
|
||||
|
||||
func CreatePublicToken(claims map[string]any) (string, error) {
|
||||
tokenClaims := createBaseClaims()
|
||||
maps.Copy(tokenClaims, claims)
|
||||
for k, v := range claims {
|
||||
tokenClaims[k] = v
|
||||
}
|
||||
_, token, err := TokenAuth.Encode(tokenClaims)
|
||||
|
||||
return token, err
|
||||
@@ -65,7 +66,9 @@ func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, er
|
||||
if !exp.IsZero() {
|
||||
tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix()
|
||||
}
|
||||
maps.Copy(tokenClaims, claims)
|
||||
for k, v := range claims {
|
||||
tokenClaims[k] = v
|
||||
}
|
||||
_, token, err := TokenAuth.Encode(tokenClaims)
|
||||
|
||||
return token, err
|
||||
@@ -97,7 +100,7 @@ func TouchToken(token jwt.Token) (string, error) {
|
||||
return newToken, err
|
||||
}
|
||||
|
||||
func Validate(tokenStr string) (map[string]any, error) {
|
||||
func Validate(tokenStr string) (map[string]interface{}, error) {
|
||||
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -45,7 +45,7 @@ var _ = Describe("Auth", func() {
|
||||
})
|
||||
|
||||
It("returns the claims from a valid JWT token", func() {
|
||||
claims := map[string]any{}
|
||||
claims := map[string]interface{}{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["iat"] = time.Now().Unix()
|
||||
claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
|
||||
@@ -58,7 +58,7 @@ var _ = Describe("Auth", func() {
|
||||
})
|
||||
|
||||
It("returns ErrExpired if the `exp` field is in the past", func() {
|
||||
claims := map[string]any{}
|
||||
claims := map[string]interface{}{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = time.Now().Add(-1 * time.Minute).Unix()
|
||||
_, tokenStr, err := auth.TokenAuth.Encode(claims)
|
||||
@@ -93,7 +93,7 @@ var _ = Describe("Auth", func() {
|
||||
Describe("TouchToken", func() {
|
||||
It("updates the expiration time", func() {
|
||||
yesterday := time.Now().Add(-oneDay)
|
||||
claims := map[string]any{}
|
||||
claims := map[string]interface{}{}
|
||||
claims["iss"] = "issuer"
|
||||
claims["exp"] = yesterday.Unix()
|
||||
token, _, err := auth.TokenAuth.Encode(claims)
|
||||
|
||||
6
core/external/extdata_helper_test.go
vendored
6
core/external/extdata_helper_test.go
vendored
@@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||
|
||||
// GetAll implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
argsSlice := make([]any, len(options))
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ..
|
||||
|
||||
// GetAll implements model.MediaFileRepository.
|
||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||
argsSlice := make([]any, len(options))
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||
|
||||
// GetAll implements model.AlbumRepository.
|
||||
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
||||
argsSlice := make([]any, len(options))
|
||||
argsSlice := make([]interface{}, len(options))
|
||||
for i, v := range options {
|
||||
argsSlice[i] = v
|
||||
}
|
||||
|
||||
4
core/external/provider.go
vendored
4
core/external/provider.go
vendored
@@ -93,7 +93,7 @@ func NewProvider(ds model.DataStore, agents Agents) Provider {
|
||||
}
|
||||
|
||||
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
|
||||
var entity any
|
||||
var entity interface{}
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return auxAlbum{}, err
|
||||
@@ -187,7 +187,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
||||
}
|
||||
|
||||
func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) {
|
||||
var entity any
|
||||
var entity interface{}
|
||||
entity, err := model.GetEntityByID(ctx, e.ds, id)
|
||||
if err != nil {
|
||||
return auxArtist{}, err
|
||||
|
||||
@@ -159,7 +159,7 @@ type libraryRepositoryWrapper struct {
|
||||
pluginManager PluginUnloader
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
|
||||
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
lib := entity.(*model.Library)
|
||||
if err := r.validateLibrary(lib); err != nil {
|
||||
return "", err
|
||||
@@ -191,7 +191,7 @@ func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
|
||||
return strconv.Itoa(lib.ID), nil
|
||||
}
|
||||
|
||||
func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error {
|
||||
func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
|
||||
lib := entity.(*model.Library)
|
||||
libID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -196,7 +196,9 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri
|
||||
// refreshStatsAsync refreshes artist and album statistics in background goroutines
|
||||
func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
|
||||
// Refresh artist stats in background
|
||||
s.wg.Go(func() {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
bgCtx := request.AddValues(context.Background(), ctx)
|
||||
if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
|
||||
log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
|
||||
@@ -212,7 +214,7 @@ func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbu
|
||||
log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
|
||||
}
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait waits for all background goroutines to complete.
|
||||
|
||||
@@ -3,7 +3,6 @@ package playback
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@@ -22,11 +21,11 @@ func NewQueue() *Queue {
|
||||
}
|
||||
|
||||
func (pd *Queue) String() string {
|
||||
var filenames strings.Builder
|
||||
filenames := ""
|
||||
for idx, item := range pd.Items {
|
||||
filenames.WriteString(fmt.Sprint(idx) + ":" + item.Path + " ")
|
||||
filenames += fmt.Sprint(idx) + ":" + item.Path + " "
|
||||
}
|
||||
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames.String())
|
||||
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
|
||||
}
|
||||
|
||||
// returns the current mediafile or nil
|
||||
|
||||
@@ -45,7 +45,7 @@ func InPlaylistsPath(folder model.Folder) bool {
|
||||
return true
|
||||
}
|
||||
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
||||
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||
if match, _ := doublestar.Match(path, rel); match {
|
||||
return true
|
||||
}
|
||||
@@ -193,8 +193,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if after, ok := strings.CutPrefix(line, "file://"); ok {
|
||||
line = after
|
||||
if strings.HasPrefix(line, "file://") {
|
||||
line = strings.TrimPrefix(line, "file://")
|
||||
line, _ = url.QueryUnescape(line)
|
||||
}
|
||||
if !model.IsAudioFile(line) {
|
||||
@@ -533,7 +533,7 @@ type nspFile struct {
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
m := map[string]any{}
|
||||
m := map[string]interface{}{}
|
||||
err := json.Unmarshal(data, &m)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -212,7 +212,10 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
|
||||
|
||||
// Calculate TTL based on remaining track duration. If position exceeds track duration,
|
||||
// remaining is set to 0 to avoid negative TTL.
|
||||
remaining := max(int(mf.Duration)-position, 0)
|
||||
remaining := int(mf.Duration) - position
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
// Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration.
|
||||
ttl := time.Duration(remaining+5) * time.Second
|
||||
_ = p.playMap.AddWithTTL(playerId, info, ttl)
|
||||
|
||||
@@ -87,7 +87,7 @@ func (r *shareRepositoryWrapper) newId() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
|
||||
func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
s := entity.(*model.Share)
|
||||
id, err := r.newId()
|
||||
if err != nil {
|
||||
@@ -127,7 +127,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error {
|
||||
func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
|
||||
cols := []string{"description", "downloadable"}
|
||||
|
||||
// TODO Better handling of Share expiration
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"net/url"
|
||||
"path"
|
||||
"testing/fstest"
|
||||
@@ -136,7 +135,9 @@ func (ffs *FakeFS) UpdateTags(filePath string, newTags map[string]any, when ...t
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
maps.Copy(tags, newTags)
|
||||
for k, v := range newTags {
|
||||
tags[k] = v
|
||||
}
|
||||
data, _ := json.Marshal(tags)
|
||||
f.Data = data
|
||||
ffs.Touch(filePath, when...)
|
||||
@@ -179,7 +180,9 @@ func Track(num int, title string, tags ...map[string]any) map[string]any {
|
||||
ts["title"] = title
|
||||
ts["track"] = num
|
||||
for _, t := range tags {
|
||||
maps.Copy(ts, t)
|
||||
for k, v := range t {
|
||||
ts[k] = v
|
||||
}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
@@ -197,7 +200,9 @@ func MP3(tags ...map[string]any) *fstest.MapFile {
|
||||
func File(tags ...map[string]any) *fstest.MapFile {
|
||||
ts := map[string]any{}
|
||||
for _, t := range tags {
|
||||
maps.Copy(ts, t)
|
||||
for k, v := range t {
|
||||
ts[k] = v
|
||||
}
|
||||
}
|
||||
modTime := time.Now()
|
||||
if mt, ok := ts[fakeFileInfoModTime]; !ok {
|
||||
|
||||
@@ -50,12 +50,12 @@ type userRepositoryWrapper struct {
|
||||
}
|
||||
|
||||
// Save implements rest.Persistable by delegating to the underlying repository.
|
||||
func (r *userRepositoryWrapper) Save(entity any) (string, error) {
|
||||
func (r *userRepositoryWrapper) Save(entity interface{}) (string, error) {
|
||||
return r.UserRepository.(rest.Persistable).Save(entity)
|
||||
}
|
||||
|
||||
// Update implements rest.Persistable by delegating to the underlying repository.
|
||||
func (r *userRepositoryWrapper) Update(id string, entity any, cols ...string) error {
|
||||
func (r *userRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error {
|
||||
return r.UserRepository.(rest.Persistable).Update(id, entity, cols...)
|
||||
}
|
||||
|
||||
|
||||
16
db/db.go
16
db/db.go
@@ -126,7 +126,7 @@ func Optimize(ctx context.Context) {
|
||||
}
|
||||
log.Debug(ctx, "Optimizing open connections", "numConns", numConns)
|
||||
var conns []*sql.Conn
|
||||
for range numConns {
|
||||
for i := 0; i < numConns; i++ {
|
||||
conn, err := Db().Conn(ctx)
|
||||
conns = append(conns, conn)
|
||||
if err != nil {
|
||||
@@ -147,8 +147,8 @@ func Optimize(ctx context.Context) {
|
||||
|
||||
type statusLogger struct{ numPending int }
|
||||
|
||||
func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }
|
||||
func (l *statusLogger) Printf(format string, v ...any) {
|
||||
func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
|
||||
func (l *statusLogger) Printf(format string, v ...interface{}) {
|
||||
if len(v) < 1 {
|
||||
return
|
||||
}
|
||||
@@ -183,27 +183,27 @@ type logAdapter struct {
|
||||
silent bool
|
||||
}
|
||||
|
||||
func (l *logAdapter) Fatal(v ...any) {
|
||||
func (l *logAdapter) Fatal(v ...interface{}) {
|
||||
log.Fatal(l.ctx, fmt.Sprint(v...))
|
||||
}
|
||||
|
||||
func (l *logAdapter) Fatalf(format string, v ...any) {
|
||||
func (l *logAdapter) Fatalf(format string, v ...interface{}) {
|
||||
log.Fatal(l.ctx, fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
func (l *logAdapter) Print(v ...any) {
|
||||
func (l *logAdapter) Print(v ...interface{}) {
|
||||
if !l.silent {
|
||||
log.Info(l.ctx, fmt.Sprint(v...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *logAdapter) Println(v ...any) {
|
||||
func (l *logAdapter) Println(v ...interface{}) {
|
||||
if !l.silent {
|
||||
log.Info(l.ctx, fmt.Sprintln(v...))
|
||||
}
|
||||
}
|
||||
|
||||
func (l *logAdapter) Printf(format string, v ...any) {
|
||||
func (l *logAdapter) Printf(format string, v ...interface{}) {
|
||||
if !l.silent {
|
||||
log.Info(l.ctx, fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
12
go.mod
12
go.mod
@@ -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.10.0
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.2
|
||||
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.5
|
||||
github.com/go-chi/chi/v5 v5.2.4
|
||||
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.28.1
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/onsi/ginkgo/v2 v2.27.5
|
||||
github.com/onsi/gomega v1.39.0
|
||||
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-20260202012954-cb029daf43ef // indirect
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // 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.1.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // 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
24
go.sum
@@ -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.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
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/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.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
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/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-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
|
||||
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
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/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.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/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/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.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
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=
|
||||
|
||||
24
log/log.go
24
log/log.go
@@ -19,7 +19,7 @@ import (
|
||||
|
||||
type Level uint32
|
||||
|
||||
type LevelFunc = func(ctx any, msg any, keyValuePairs ...any)
|
||||
type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{})
|
||||
|
||||
var redacted = &Hook{
|
||||
AcceptedLevels: logrus.AllLevels,
|
||||
@@ -152,7 +152,7 @@ func Redact(msg string) string {
|
||||
return r
|
||||
}
|
||||
|
||||
func NewContext(ctx context.Context, keyValuePairs ...any) context.Context {
|
||||
func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
@@ -184,32 +184,32 @@ func IsGreaterOrEqualTo(level Level) bool {
|
||||
return shouldLog(level, 2)
|
||||
}
|
||||
|
||||
func Fatal(args ...any) {
|
||||
func Fatal(args ...interface{}) {
|
||||
Log(LevelFatal, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func Error(args ...any) {
|
||||
func Error(args ...interface{}) {
|
||||
Log(LevelError, args...)
|
||||
}
|
||||
|
||||
func Warn(args ...any) {
|
||||
func Warn(args ...interface{}) {
|
||||
Log(LevelWarn, args...)
|
||||
}
|
||||
|
||||
func Info(args ...any) {
|
||||
func Info(args ...interface{}) {
|
||||
Log(LevelInfo, args...)
|
||||
}
|
||||
|
||||
func Debug(args ...any) {
|
||||
func Debug(args ...interface{}) {
|
||||
Log(LevelDebug, args...)
|
||||
}
|
||||
|
||||
func Trace(args ...any) {
|
||||
func Trace(args ...interface{}) {
|
||||
Log(LevelTrace, args...)
|
||||
}
|
||||
|
||||
func Log(level Level, args ...any) {
|
||||
func Log(level Level, args ...interface{}) {
|
||||
if !shouldLog(level, 3) {
|
||||
return
|
||||
}
|
||||
@@ -250,7 +250,7 @@ func shouldLog(requiredLevel Level, skip int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func parseArgs(args []any) (*logrus.Entry, string) {
|
||||
func parseArgs(args []interface{}) (*logrus.Entry, string) {
|
||||
var l *logrus.Entry
|
||||
var err error
|
||||
if args[0] == nil {
|
||||
@@ -289,7 +289,7 @@ func parseArgs(args []any) (*logrus.Entry, string) {
|
||||
return l, ""
|
||||
}
|
||||
|
||||
func addFields(logger *logrus.Entry, keyValuePairs []any) *logrus.Entry {
|
||||
func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry {
|
||||
for i := 0; i < len(keyValuePairs); i += 2 {
|
||||
switch name := keyValuePairs[i].(type) {
|
||||
case error:
|
||||
@@ -316,7 +316,7 @@ func addFields(logger *logrus.Entry, keyValuePairs []any) *logrus.Entry {
|
||||
return logger
|
||||
}
|
||||
|
||||
func extractLogger(ctx any) (*logrus.Entry, error) {
|
||||
func extractLogger(ctx interface{}) (*logrus.Entry, error) {
|
||||
switch ctx := ctx.(type) {
|
||||
case *logrus.Entry:
|
||||
return ctx, nil
|
||||
|
||||
@@ -41,7 +41,7 @@ type DataStore interface {
|
||||
Scrobble(ctx context.Context) ScrobbleRepository
|
||||
Plugin(ctx context.Context) PluginRepository
|
||||
|
||||
Resource(ctx context.Context, model any) ResourceRepository
|
||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||
|
||||
WithTx(block func(tx DataStore) error, scope ...string) error
|
||||
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
)
|
||||
|
||||
// TODO: Should the type be encoded in the ID?
|
||||
func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
|
||||
func GetEntityByID(ctx context.Context, ds DataStore, id string) (interface{}, error) {
|
||||
ar, err := ds.Artist(ctx).Get(id)
|
||||
if err == nil {
|
||||
return ar, nil
|
||||
|
||||
@@ -140,7 +140,7 @@ func (mf MediaFile) Hash() string {
|
||||
}
|
||||
hash, _ := hashstructure.Hash(mf, opts)
|
||||
sum := md5.New()
|
||||
sum.Write(fmt.Appendf(nil, "%d", hash))
|
||||
sum.Write([]byte(fmt.Sprintf("%d", hash)))
|
||||
sum.Write(mf.Tags.Hash())
|
||||
sum.Write(mf.Participants.Hash())
|
||||
return fmt.Sprintf("%x", sum.Sum(nil))
|
||||
@@ -354,7 +354,7 @@ type MediaFileCursor iter.Seq2[MediaFile, error]
|
||||
type MediaFileRepository interface {
|
||||
CountAll(options ...QueryOptions) (int64, error)
|
||||
CountBySuffix(options ...QueryOptions) (map[string]int64, error)
|
||||
Exists(id string) (bool, error)
|
||||
Exists(ids ...string) (bool, error)
|
||||
Put(m *MediaFile) error
|
||||
Get(id string) (*MediaFile, error)
|
||||
GetWithParticipants(id string) (*MediaFile, error)
|
||||
|
||||
@@ -250,15 +250,7 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model
|
||||
id3Base := parseID3Pairs(name, lowered)
|
||||
|
||||
if len(aliasValues) > 0 {
|
||||
// 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)...)
|
||||
}
|
||||
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
|
||||
}
|
||||
return id3Base
|
||||
}
|
||||
@@ -268,8 +260,8 @@ func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
|
||||
prefix := string(name) + ":"
|
||||
for tagKey, tagValues := range lowered {
|
||||
keyStr := string(tagKey)
|
||||
if after, ok := strings.CutPrefix(keyStr, prefix); ok {
|
||||
keyPart := after
|
||||
if strings.HasPrefix(keyStr, prefix) {
|
||||
keyPart := strings.TrimPrefix(keyStr, prefix)
|
||||
if keyPart == string(name) {
|
||||
keyPart = ""
|
||||
}
|
||||
|
||||
@@ -246,18 +246,6 @@ 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() {
|
||||
|
||||
@@ -49,8 +49,8 @@ func createGetPID(hash hashFunc) getPIDFunc {
|
||||
}
|
||||
getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
||||
pid := ""
|
||||
fields := strings.SplitSeq(spec, "|")
|
||||
for field := range fields {
|
||||
fields := strings.Split(spec, "|")
|
||||
for _, field := range fields {
|
||||
attributes := strings.Split(field, ",")
|
||||
hasValue := false
|
||||
values := slice.Map(attributes, func(attr string) string {
|
||||
|
||||
@@ -51,13 +51,13 @@ func ParseTargets(libFolders []string) ([]ScanTarget, error) {
|
||||
}
|
||||
|
||||
// Split by the first colon
|
||||
before, after, ok := strings.Cut(part, ":")
|
||||
if !ok {
|
||||
colonIdx := strings.Index(part, ":")
|
||||
if colonIdx == -1 {
|
||||
return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part)
|
||||
}
|
||||
|
||||
libIDStr := before
|
||||
folderPath := after
|
||||
libIDStr := part[:colonIdx]
|
||||
folderPath := part[colonIdx+1:]
|
||||
|
||||
libID, err := strconv.Atoi(libIDStr)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,8 +22,8 @@ type Share struct {
|
||||
Format string `structs:"format" json:"format,omitempty"`
|
||||
MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate,omitempty"`
|
||||
VisitCount int `structs:"visit_count" json:"visitCount,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt,omitempty"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
|
||||
Tracks MediaFiles `structs:"-" json:"tracks,omitempty"`
|
||||
Albums Albums `structs:"-" json:"albums,omitempty"`
|
||||
URL string `structs:"-" json:"-"`
|
||||
|
||||
@@ -144,8 +144,10 @@ func (t Tags) Merge(tags Tags) {
|
||||
}
|
||||
|
||||
func (t Tags) Add(name TagName, v string) {
|
||||
if slices.Contains(t[name], v) {
|
||||
return
|
||||
for _, existing := range t[name] {
|
||||
if existing == v {
|
||||
return
|
||||
}
|
||||
}
|
||||
t[name] = append(t[name], v)
|
||||
}
|
||||
|
||||
@@ -145,11 +145,11 @@ func recentlyAddedSort() string {
|
||||
return "created_at"
|
||||
}
|
||||
|
||||
func recentlyPlayedFilter(string, any) Sqlizer {
|
||||
func recentlyPlayedFilter(string, interface{}) Sqlizer {
|
||||
return Gt{"play_count": 0}
|
||||
}
|
||||
|
||||
func yearFilter(_ string, value any) Sqlizer {
|
||||
func yearFilter(_ string, value interface{}) Sqlizer {
|
||||
return Or{
|
||||
And{
|
||||
Gt{"min_year": 0},
|
||||
@@ -160,14 +160,14 @@ func yearFilter(_ string, value any) Sqlizer {
|
||||
}
|
||||
}
|
||||
|
||||
func artistFilter(_ string, value any) Sqlizer {
|
||||
func artistFilter(_ string, value interface{}) Sqlizer {
|
||||
return Or{
|
||||
Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}),
|
||||
Exists("json_tree(participants, '$.artist')", Eq{"value": value}),
|
||||
}
|
||||
}
|
||||
|
||||
func artistRoleFilter(name string, value any) Sqlizer {
|
||||
func artistRoleFilter(name string, value interface{}) Sqlizer {
|
||||
roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id")
|
||||
|
||||
// Check if the role name is valid. If not, return an invalid filter
|
||||
@@ -177,7 +177,7 @@ func artistRoleFilter(name string, value any) Sqlizer {
|
||||
return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value})
|
||||
}
|
||||
|
||||
func allRolesFilter(_ string, value any) Sqlizer {
|
||||
func allRolesFilter(_ string, value interface{}) Sqlizer {
|
||||
return Like{"participants": fmt.Sprintf(`%%"%s"%%`, value)}
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting album to copy fields from: %w", err)
|
||||
}
|
||||
to := make(map[string]any)
|
||||
to := make(map[string]interface{})
|
||||
for _, col := range columns {
|
||||
to[col] = from[col]
|
||||
}
|
||||
@@ -370,11 +370,11 @@ func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *albumRepository) Read(id string) (any, error) {
|
||||
func (r *albumRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
@@ -382,7 +382,7 @@ func (r *albumRepository) EntityName() string {
|
||||
return "album"
|
||||
}
|
||||
|
||||
func (r *albumRepository) NewInstance() any {
|
||||
func (r *albumRepository) NewInstance() interface{} {
|
||||
return &model.Album{}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
|
||||
newID := id.NewRandom()
|
||||
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
for range playCount {
|
||||
for i := 0; i < playCount; i++ {
|
||||
Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed())
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
|
||||
newID := id.NewRandom()
|
||||
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed())
|
||||
for range playCount {
|
||||
for i := 0; i < playCount; i++ {
|
||||
Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed())
|
||||
}
|
||||
|
||||
@@ -406,7 +406,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal(expectedSQL))
|
||||
Expect(args).To(Equal([]any{artistID}))
|
||||
Expect(args).To(Equal([]interface{}{artistID}))
|
||||
},
|
||||
Entry("artist role", "role_artist_id", "123",
|
||||
"exists (select 1 from json_tree(participants, '$.artist') where value = ?)"),
|
||||
@@ -428,7 +428,7 @@ var _ = Describe("AlbumRepository", func() {
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal(fmt.Sprintf("exists (select 1 from json_tree(participants, '$.%s') where value = ?)", roleName)))
|
||||
Expect(args).To(Equal([]any{"test-id"}))
|
||||
Expect(args).To(Equal([]interface{}{"test-id"}))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ func roleFilter(_ string, role any) Sqlizer {
|
||||
}
|
||||
|
||||
// artistLibraryIdFilter filters artists based on library access through the library_artist table
|
||||
func artistLibraryIdFilter(_ string, value any) Sqlizer {
|
||||
func artistLibraryIdFilter(_ string, value interface{}) Sqlizer {
|
||||
return Eq{"library_artist.library_id": value}
|
||||
}
|
||||
|
||||
@@ -534,11 +534,11 @@ func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *artistRepository) Read(id string) (any, error) {
|
||||
func (r *artistRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
role := "total"
|
||||
if len(options) > 0 {
|
||||
if v, ok := options[0].Filters["role"].(string); ok {
|
||||
@@ -555,7 +555,7 @@ func (r *artistRepository) EntityName() string {
|
||||
return "artist"
|
||||
}
|
||||
|
||||
func (r *artistRepository) NewInstance() any {
|
||||
func (r *artistRepository) NewInstance() interface{} {
|
||||
return &model.Artist{}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -118,7 +117,9 @@ func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maps.Copy(result, batchResult)
|
||||
for id, info := range batchResult {
|
||||
result[id] = info
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -33,18 +33,18 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error
|
||||
|
||||
// Override ResourceRepository methods to return Genre objects instead of Tag objects
|
||||
|
||||
func (r *genreRepository) Read(id string) (any, error) {
|
||||
func (r *genreRepository) Read(id string) (interface{}, error) {
|
||||
sel := r.selectGenre().Where(Eq{"tag.id": id})
|
||||
var res model.Genre
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *genreRepository) NewInstance() any {
|
||||
func (r *genreRepository) NewInstance() interface{} {
|
||||
return &model.Genre{}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ var _ = Describe("GenreRepository", func() {
|
||||
It("should filter by name using like match", func() {
|
||||
// Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value")
|
||||
options := rest.QueryOptions{
|
||||
Filters: map[string]any{"name": "%rock%"},
|
||||
Filters: map[string]interface{}{"name": "%rock%"},
|
||||
}
|
||||
count, err := restRepo.Count(options)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -289,7 +289,7 @@ var _ = Describe("GenreRepository", func() {
|
||||
It("should allow headless processes to apply explicit library_id filters", func() {
|
||||
// Filter by specific library
|
||||
genres, err := headlessRestRepo.ReadAll(rest.QueryOptions{
|
||||
Filters: map[string]any{"library_id": 2},
|
||||
Filters: map[string]interface{}{"library_id": 2},
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ type PostMapper interface {
|
||||
PostMapArgs(map[string]any) error
|
||||
}
|
||||
|
||||
func toSQLArgs(rec any) (map[string]any, error) {
|
||||
func toSQLArgs(rec interface{}) (map[string]interface{}, error) {
|
||||
m := structs.Map(rec)
|
||||
for k, v := range m {
|
||||
switch t := v.(type) {
|
||||
@@ -71,7 +71,7 @@ type existsCond struct {
|
||||
not bool
|
||||
}
|
||||
|
||||
func (e existsCond) ToSql() (string, []any, error) {
|
||||
func (e existsCond) ToSql() (string, []interface{}, error) {
|
||||
sql, args, err := e.cond.ToSql()
|
||||
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
|
||||
if e.not {
|
||||
|
||||
@@ -305,7 +305,7 @@ func (r *libraryRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *libraryRepository) Read(id string) (any, error) {
|
||||
func (r *libraryRepository) Read(id string) (interface{}, error) {
|
||||
idInt, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
log.Trace(r.ctx, "invalid library id: %s", id, err)
|
||||
@@ -314,7 +314,7 @@ func (r *libraryRepository) Read(id string) (any, error) {
|
||||
return r.Get(idInt)
|
||||
}
|
||||
|
||||
func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
@@ -322,11 +322,11 @@ func (r *libraryRepository) EntityName() string {
|
||||
return "library"
|
||||
}
|
||||
|
||||
func (r *libraryRepository) NewInstance() any {
|
||||
func (r *libraryRepository) NewInstance() interface{} {
|
||||
return &model.Library{}
|
||||
}
|
||||
|
||||
func (r *libraryRepository) Save(entity any) (string, error) {
|
||||
func (r *libraryRepository) Save(entity interface{}) (string, error) {
|
||||
lib := entity.(*model.Library)
|
||||
lib.ID = 0 // Reset ID to ensure we create a new library
|
||||
err := r.Put(lib)
|
||||
@@ -336,7 +336,7 @@ func (r *libraryRepository) Save(entity any) (string, error) {
|
||||
return strconv.Itoa(lib.ID), nil
|
||||
}
|
||||
|
||||
func (r *libraryRepository) Update(id string, entity any, cols ...string) error {
|
||||
func (r *libraryRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
lib := entity.(*model.Library)
|
||||
idInt, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -143,8 +143,29 @@ func (r *mediaFileRepository) CountBySuffix(options ...model.QueryOptions) (map[
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Exists(id string) (bool, error) {
|
||||
return r.exists(Eq{"media_file.id": id})
|
||||
// Exists checks if all given media file IDs exist in the database and are accessible to the current user.
|
||||
// If no IDs are provided, it returns true. Duplicate IDs are handled correctly.
|
||||
// If any of the IDs do not exist or are not accessible, it returns false.
|
||||
func (r *mediaFileRepository) Exists(ids ...string) (bool, error) {
|
||||
if len(ids) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
uniqueIds := slice.Unique(ids)
|
||||
|
||||
// Process in batches to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit (default 999)
|
||||
const batchSize = 300
|
||||
var totalCount int64
|
||||
for batch := range slices.Chunk(uniqueIds, batchSize) {
|
||||
existsQuery := Select("count(*) as exist").From("media_file").Where(Eq{"media_file.id": batch})
|
||||
existsQuery = r.applyLibraryFilter(existsQuery)
|
||||
var res struct{ Exist int64 }
|
||||
err := r.queryOne(existsQuery, &res)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
totalCount += res.Exist
|
||||
}
|
||||
return totalCount == int64(len(uniqueIds)), nil
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
@@ -443,11 +464,11 @@ func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error)
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Read(id string) (any, error) {
|
||||
func (r *mediaFileRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
@@ -455,7 +476,7 @@ func (r *mediaFileRepository) EntityName() string {
|
||||
return "mediafile"
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) NewInstance() any {
|
||||
func (r *mediaFileRepository) NewInstance() interface{} {
|
||||
return &model.MediaFile{}
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +310,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
// Update "Old Song": created long ago, updated recently
|
||||
_, err := db.Update("media_file",
|
||||
map[string]any{
|
||||
map[string]interface{}{
|
||||
"created_at": oldTime,
|
||||
"updated_at": newTime,
|
||||
},
|
||||
@@ -319,7 +319,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
// Update "Middle Song": created and updated at the same middle time
|
||||
_, err = db.Update("media_file",
|
||||
map[string]any{
|
||||
map[string]interface{}{
|
||||
"created_at": middleTime,
|
||||
"updated_at": middleTime,
|
||||
},
|
||||
@@ -328,7 +328,7 @@ var _ = Describe("MediaRepository", func() {
|
||||
|
||||
// Update "New Song": created recently, updated long ago
|
||||
_, err = db.Update("media_file",
|
||||
map[string]any{
|
||||
map[string]interface{}{
|
||||
"created_at": newTime,
|
||||
"updated_at": oldTime,
|
||||
},
|
||||
|
||||
@@ -97,7 +97,7 @@ func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository {
|
||||
return NewPluginRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository {
|
||||
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||
switch m.(type) {
|
||||
case model.User:
|
||||
return s.User(ctx).(model.ResourceRepository)
|
||||
|
||||
@@ -103,14 +103,14 @@ func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playerRepository) Read(id string) (any, error) {
|
||||
func (r *playerRepository) Read(id string) (interface{}, error) {
|
||||
sel := r.newRestSelect().Where(Eq{"player.id": id})
|
||||
var res model.Player
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...))
|
||||
res := model.Players{}
|
||||
err := r.queryAll(sel, &res)
|
||||
@@ -121,7 +121,7 @@ func (r *playerRepository) EntityName() string {
|
||||
return "player"
|
||||
}
|
||||
|
||||
func (r *playerRepository) NewInstance() any {
|
||||
func (r *playerRepository) NewInstance() interface{} {
|
||||
return &model.Player{}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ func (r *playerRepository) isPermitted(p *model.Player) bool {
|
||||
return u.IsAdmin || p.UserId == u.ID
|
||||
}
|
||||
|
||||
func (r *playerRepository) Save(entity any) (string, error) {
|
||||
func (r *playerRepository) Save(entity interface{}) (string, error) {
|
||||
t := entity.(*model.Player)
|
||||
if !r.isPermitted(t) {
|
||||
return "", rest.ErrPermissionDenied
|
||||
@@ -142,7 +142,7 @@ func (r *playerRepository) Save(entity any) (string, error) {
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *playerRepository) Update(id string, entity any, cols ...string) error {
|
||||
func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
t := entity.(*model.Player)
|
||||
t.ID = id
|
||||
if !r.isPermitted(t) {
|
||||
|
||||
@@ -61,14 +61,14 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
|
||||
return r
|
||||
}
|
||||
|
||||
func playlistFilter(_ string, value any) Sqlizer {
|
||||
func playlistFilter(_ string, value interface{}) Sqlizer {
|
||||
return Or{
|
||||
substringFilter("playlist.name", value),
|
||||
substringFilter("playlist.comment", value),
|
||||
}
|
||||
}
|
||||
|
||||
func smartPlaylistFilter(string, any) Sqlizer {
|
||||
func smartPlaylistFilter(string, interface{}) Sqlizer {
|
||||
return Or{
|
||||
Eq{"rules": ""},
|
||||
Eq{"rules": nil},
|
||||
@@ -285,16 +285,13 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
}
|
||||
|
||||
// Update when the playlist was last refreshed (for cache purposes)
|
||||
now := time.Now()
|
||||
updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
|
||||
updSql := Update(r.tableName).Set("evaluated_at", time.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
|
||||
@@ -421,11 +418,11 @@ func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error)
|
||||
return r.CountAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Read(id string) (any, error) {
|
||||
func (r *playlistRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
@@ -433,11 +430,11 @@ func (r *playlistRepository) EntityName() string {
|
||||
return "playlist"
|
||||
}
|
||||
|
||||
func (r *playlistRepository) NewInstance() any {
|
||||
func (r *playlistRepository) NewInstance() interface{} {
|
||||
return &model.Playlist{}
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Save(entity any) (string, error) {
|
||||
func (r *playlistRepository) Save(entity interface{}) (string, error) {
|
||||
pls := entity.(*model.Playlist)
|
||||
pls.OwnerID = loggedUser(r.ctx).ID
|
||||
pls.ID = "" // Make sure we don't override an existing playlist
|
||||
@@ -448,7 +445,7 @@ func (r *playlistRepository) Save(entity any) (string, error) {
|
||||
return pls.ID, err
|
||||
}
|
||||
|
||||
func (r *playlistRepository) Update(id string, entity any, cols ...string) error {
|
||||
func (r *playlistRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
|
||||
current, err := r.Get(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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"
|
||||
@@ -161,23 +160,14 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("child smart playlists", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
When("refresh delay has expired", func() {
|
||||
// TODO Validate these tests
|
||||
XContext("child smart playlists", func() {
|
||||
When("refresh day has expired", func() {
|
||||
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
|
||||
childRules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Contains{"title": "Day"},
|
||||
},
|
||||
}
|
||||
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
|
||||
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
|
||||
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{
|
||||
@@ -185,69 +175,45 @@ 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())
|
||||
|
||||
// Getting parent with refresh should recursively refresh the nested playlist
|
||||
pls, err := repo.GetWithTracks(parentPls.ID, true, false)
|
||||
_, 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))
|
||||
|
||||
// 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)
|
||||
// Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get
|
||||
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
|
||||
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
|
||||
|
||||
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally(">", *nestedPlsRead.EvaluatedAt))
|
||||
})
|
||||
})
|
||||
|
||||
When("refresh delay has not expired", func() {
|
||||
When("refresh day 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)
|
||||
|
||||
childRules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Contains{"title": "Day"},
|
||||
},
|
||||
}
|
||||
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt}
|
||||
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
|
||||
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())
|
||||
|
||||
// Getting parent with refresh should NOT recursively refresh the nested playlist
|
||||
parent, err := repo.GetWithTracks(parentPls.ID, true, false)
|
||||
_, err = repo.GetWithTracks(parentPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// 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)
|
||||
// Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get
|
||||
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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,7 +84,7 @@ func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, er
|
||||
return r.count(query, r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) Read(id string) (any, error) {
|
||||
func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
sel := r.newSelect().
|
||||
LeftJoin("annotation on ("+
|
||||
@@ -128,7 +128,7 @@ func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ func (r *playlistTrackRepository) EntityName() string {
|
||||
return "playlist_tracks"
|
||||
}
|
||||
|
||||
func (r *playlistTrackRepository) NewInstance() any {
|
||||
func (r *playlistTrackRepository) NewInstance() interface{} {
|
||||
return &model.PlaylistTrack{}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,8 +122,8 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
|
||||
UpdatedAt: pq.UpdatedAt,
|
||||
}
|
||||
if strings.TrimSpace(pq.Items) != "" {
|
||||
tracks := strings.SplitSeq(pq.Items, ",")
|
||||
for t := range tracks {
|
||||
tracks := strings.Split(pq.Items, ",")
|
||||
for _, t := range tracks {
|
||||
q.Items = append(q.Items, model.MediaFile{ID: t})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (r *radioRepository) Put(radio *model.Radio) error {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
var values map[string]any
|
||||
var values map[string]interface{}
|
||||
|
||||
radio.UpdatedAt = time.Now()
|
||||
|
||||
@@ -97,19 +97,19 @@ func (r *radioRepository) EntityName() string {
|
||||
return "radio"
|
||||
}
|
||||
|
||||
func (r *radioRepository) NewInstance() any {
|
||||
func (r *radioRepository) NewInstance() interface{} {
|
||||
return &model.Radio{}
|
||||
}
|
||||
|
||||
func (r *radioRepository) Read(id string) (any, error) {
|
||||
func (r *radioRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
return r.GetAll(r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *radioRepository) Save(entity any) (string, error) {
|
||||
func (r *radioRepository) Save(entity interface{}) (string, error) {
|
||||
t := entity.(*model.Radio)
|
||||
if !r.isPermitted() {
|
||||
return "", rest.ErrPermissionDenied
|
||||
@@ -121,7 +121,7 @@ func (r *radioRepository) Save(entity any) (string, error) {
|
||||
return t.ID, err
|
||||
}
|
||||
|
||||
func (r *radioRepository) Update(id string, entity any, cols ...string) error {
|
||||
func (r *radioRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
t := entity.(*model.Radio)
|
||||
t.ID = id
|
||||
if !r.isPermitted() {
|
||||
|
||||
@@ -51,7 +51,7 @@ func (r *scrobbleBufferRepository) UserIDs(service string) ([]string, error) {
|
||||
}
|
||||
|
||||
func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, playTime time.Time) error {
|
||||
ins := Insert(r.tableName).SetMap(map[string]any{
|
||||
ins := Insert(r.tableName).SetMap(map[string]interface{}{
|
||||
"id": id.NewRandom(),
|
||||
"user_id": userId,
|
||||
"service": service,
|
||||
|
||||
@@ -24,7 +24,7 @@ var _ = Describe("ScrobbleBufferRepository", func() {
|
||||
id := id.NewRandom()
|
||||
ids = append(ids, id)
|
||||
|
||||
ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]any{
|
||||
ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]interface{}{
|
||||
"id": id,
|
||||
"user_id": userId,
|
||||
"service": service,
|
||||
|
||||
@@ -23,7 +23,7 @@ func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRe
|
||||
|
||||
func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
values := map[string]any{
|
||||
values := map[string]interface{}{
|
||||
"media_file_id": mediaFileID,
|
||||
"user_id": userID,
|
||||
"submission_time": submissionTime.Unix(),
|
||||
|
||||
@@ -138,7 +138,7 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles {
|
||||
return sorted
|
||||
}
|
||||
|
||||
func (r *shareRepository) Update(id string, entity any, cols ...string) error {
|
||||
func (r *shareRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
s := entity.(*model.Share)
|
||||
// TODO Validate record
|
||||
s.ID = id
|
||||
@@ -151,7 +151,7 @@ func (r *shareRepository) Update(id string, entity any, cols ...string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *shareRepository) Save(entity any) (string, error) {
|
||||
func (r *shareRepository) Save(entity interface{}) (string, error) {
|
||||
s := entity.(*model.Share)
|
||||
// TODO Validate record
|
||||
u := loggedUser(r.ctx)
|
||||
@@ -179,18 +179,18 @@ func (r *shareRepository) EntityName() string {
|
||||
return "share"
|
||||
}
|
||||
|
||||
func (r *shareRepository) NewInstance() any {
|
||||
func (r *shareRepository) NewInstance() interface{} {
|
||||
return &model.Share{}
|
||||
}
|
||||
|
||||
func (r *shareRepository) Read(id string) (any, error) {
|
||||
func (r *shareRepository) Read(id string) (interface{}, error) {
|
||||
sel := r.selectShare().Where(Eq{"share.id": id})
|
||||
var res model.Share
|
||||
err := r.queryOne(sel, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sq := r.selectShare(r.parseRestOptions(r.ctx, options...))
|
||||
res := model.Shares{}
|
||||
err := r.queryAll(sq, &res)
|
||||
|
||||
@@ -47,7 +47,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
_, err := GetDBXBuilder().NewQuery(`
|
||||
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
|
||||
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
|
||||
`).Bind(map[string]any{
|
||||
`).Bind(map[string]interface{}{
|
||||
"id": shareID,
|
||||
"user": adminUser.ID,
|
||||
"desc": "Headless Test Share",
|
||||
@@ -79,7 +79,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
_, err := GetDBXBuilder().NewQuery(`
|
||||
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
|
||||
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
|
||||
`).Bind(map[string]any{
|
||||
`).Bind(map[string]interface{}{
|
||||
"id": shareID,
|
||||
"user": adminUser.ID,
|
||||
"desc": "Headless Get Share",
|
||||
@@ -110,7 +110,7 @@ var _ = Describe("ShareRepository", func() {
|
||||
_, err := GetDBXBuilder().NewQuery(`
|
||||
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
|
||||
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
|
||||
`).Bind(map[string]any{
|
||||
`).Bind(map[string]interface{}{
|
||||
"id": shareID,
|
||||
"user": adminUser.ID,
|
||||
"desc": "SQL Test Share",
|
||||
|
||||
@@ -66,7 +66,7 @@ func (r sqlRepository) annId(itemID ...string) And {
|
||||
}
|
||||
}
|
||||
|
||||
func (r sqlRepository) annUpsert(values map[string]any, itemIDs ...string) error {
|
||||
func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error {
|
||||
upd := Update(annotationTable).Where(r.annId(itemIDs...))
|
||||
for f, v := range values {
|
||||
upd = upd.Set(f, v)
|
||||
@@ -90,12 +90,12 @@ func (r sqlRepository) annUpsert(values map[string]any, itemIDs ...string) error
|
||||
|
||||
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
||||
starredAt := time.Now()
|
||||
return r.annUpsert(map[string]any{"starred": starred, "starred_at": starredAt}, ids...)
|
||||
return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
|
||||
}
|
||||
|
||||
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
||||
ratedAt := time.Now()
|
||||
err := r.annUpsert(map[string]any{"rating": rating, "rated_at": ratedAt}, itemID)
|
||||
err := r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||
|
||||
if c == 0 || errors.Is(err, sql.ErrNoRows) {
|
||||
userID := loggedUser(r.ctx).ID
|
||||
values := map[string]any{}
|
||||
values := map[string]interface{}{}
|
||||
values["user_id"] = userID
|
||||
values["item_type"] = r.tableName
|
||||
values["item_id"] = itemID
|
||||
|
||||
@@ -32,17 +32,17 @@ var _ = Describe("Annotation Filters", func() {
|
||||
|
||||
Describe("annotationBoolFilter", func() {
|
||||
DescribeTable("creates correct SQL expressions",
|
||||
func(field, value string, expectedSQL string, expectedArgs []any) {
|
||||
func(field, value string, expectedSQL string, expectedArgs []interface{}) {
|
||||
sqlizer := annotationBoolFilter(field)(field, value)
|
||||
sql, args, err := sqlizer.ToSql()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(sql).To(Equal(expectedSQL))
|
||||
Expect(args).To(Equal(expectedArgs))
|
||||
},
|
||||
Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []any(nil)),
|
||||
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)),
|
||||
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)),
|
||||
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)),
|
||||
Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []interface{}(nil)),
|
||||
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []interface{}(nil)),
|
||||
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []interface{}(nil)),
|
||||
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []interface{}(nil)),
|
||||
)
|
||||
|
||||
It("returns nil if value is not a string", func() {
|
||||
|
||||
@@ -196,7 +196,7 @@ func (r *sqlRepository) withTableName(filter filterFunc) filterFunc {
|
||||
}
|
||||
|
||||
// libraryIdFilter is a filter function to be added to resources that have a library_id column.
|
||||
func libraryIdFilter(_ string, value any) Sqlizer {
|
||||
func libraryIdFilter(_ string, value interface{}) Sqlizer {
|
||||
return Eq{"library_id": value}
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ func (r sqlRepository) toSQL(sq Sqlizer) (string, dbx.Params, error) {
|
||||
return result, params, nil
|
||||
}
|
||||
|
||||
func (r sqlRepository) queryOne(sq Sqlizer, response any) error {
|
||||
func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error {
|
||||
query, args, err := r.toSQL(sq)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -328,7 +328,7 @@ func queryWithStableResults[T any](r sqlRepository, sq SelectBuilder, options ..
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r sqlRepository) queryAll(sq SelectBuilder, response any, options ...model.QueryOptions) error {
|
||||
func (r sqlRepository) queryAll(sq SelectBuilder, response interface{}, options ...model.QueryOptions) error {
|
||||
if len(options) > 0 && options[0].Offset > 0 {
|
||||
sq = r.optimizePagination(sq, options[0])
|
||||
}
|
||||
@@ -347,7 +347,7 @@ func (r sqlRepository) queryAll(sq SelectBuilder, response any, options ...model
|
||||
}
|
||||
|
||||
// queryAllSlice is a helper function to query a single column and return the result in a slice
|
||||
func (r sqlRepository) queryAllSlice(sq SelectBuilder, response any) error {
|
||||
func (r sqlRepository) queryAllSlice(sq SelectBuilder, response interface{}) error {
|
||||
query, args, err := r.toSQL(sq)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -394,7 +394,7 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt
|
||||
return res.Count, err
|
||||
}
|
||||
|
||||
func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate ...string) (string, error) {
|
||||
func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, colsToUpdate ...string) (string, error) {
|
||||
if id != "" {
|
||||
return r.put(id, m, colsToUpdate...)
|
||||
}
|
||||
@@ -408,14 +408,14 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate
|
||||
return r.put(res.ID, m, colsToUpdate...)
|
||||
}
|
||||
|
||||
func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) {
|
||||
func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (newId string, err error) {
|
||||
values, err := toSQLArgs(m)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error preparing values to write to DB: %w", err)
|
||||
}
|
||||
// If there's an ID, try to update first
|
||||
if id != "" {
|
||||
updateValues := map[string]any{}
|
||||
updateValues := map[string]interface{}{}
|
||||
|
||||
// This is a map of the columns that need to be updated, if specified
|
||||
c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {
|
||||
|
||||
@@ -37,7 +37,7 @@ func (r sqlRepository) bmkID(itemID ...string) And {
|
||||
func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error {
|
||||
client, _ := request.ClientFrom(r.ctx)
|
||||
user, _ := request.UserFrom(r.ctx)
|
||||
values := map[string]any{
|
||||
values := map[string]interface{}{
|
||||
"comment": comment,
|
||||
"position": position,
|
||||
"updated_at": time.Now(),
|
||||
|
||||
@@ -30,7 +30,7 @@ var _ = Describe("sqlRestful", func() {
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"name": fullTextFilter("table"),
|
||||
}
|
||||
options.Filters = map[string]any{"name": "'"}
|
||||
options.Filters = map[string]interface{}{"name": "'"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
|
||||
})
|
||||
|
||||
@@ -40,32 +40,32 @@ var _ = Describe("sqlRestful", func() {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
options.Filters = map[string]any{"name": "joe"}
|
||||
options.Filters = map[string]interface{}{"name": "joe"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("returns a '=' condition for 'id' filter", func() {
|
||||
options.Filters = map[string]any{"id": "123"}
|
||||
options.Filters = map[string]interface{}{"id": "123"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}}))
|
||||
})
|
||||
|
||||
It("returns a 'in' condition for multiples 'id' filters", func() {
|
||||
options.Filters = map[string]any{"id": []string{"123", "456"}}
|
||||
options.Filters = map[string]interface{}{"id": []string{"123", "456"}}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}}))
|
||||
})
|
||||
|
||||
It("returns a 'like' condition for other filters", func() {
|
||||
options.Filters = map[string]any{"name": "joe"}
|
||||
options.Filters = map[string]interface{}{"name": "joe"}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}}))
|
||||
})
|
||||
|
||||
It("uses the custom filter", func() {
|
||||
r.filterMappings = map[string]filterFunc{
|
||||
"test": func(field string, value any) squirrel.Sqlizer {
|
||||
"test": func(field string, value interface{}) squirrel.Sqlizer {
|
||||
return squirrel.Gt{field: value}
|
||||
},
|
||||
}
|
||||
options.Filters = map[string]any{"test": 100}
|
||||
options.Filters = map[string]interface{}{"test": 100}
|
||||
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ func tagIDFilter(name string, idValue any) Sqlizer {
|
||||
}
|
||||
|
||||
// tagLibraryIdFilter filters tags based on library access through the library_tag table
|
||||
func tagLibraryIdFilter(_ string, value any) Sqlizer {
|
||||
func tagLibraryIdFilter(_ string, value interface{}) Sqlizer {
|
||||
return Eq{"library_tag.library_id": value}
|
||||
}
|
||||
|
||||
@@ -142,14 +142,14 @@ func (r *baseTagRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return r.count(sq, r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *baseTagRepository) Read(id string) (any, error) {
|
||||
func (r *baseTagRepository) Read(id string) (interface{}, error) {
|
||||
query := r.newSelect().Where(Eq{"id": id})
|
||||
var res model.Tag
|
||||
err := r.queryOne(query, &res)
|
||||
return &res, err
|
||||
}
|
||||
|
||||
func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
query := r.newSelect(r.parseRestOptions(r.ctx, options...))
|
||||
var res model.TagList
|
||||
err := r.queryAll(query, &res)
|
||||
@@ -160,7 +160,7 @@ func (r *baseTagRepository) EntityName() string {
|
||||
return "tag"
|
||||
}
|
||||
|
||||
func (r *baseTagRepository) NewInstance() any {
|
||||
func (r *baseTagRepository) NewInstance() interface{} {
|
||||
return model.Tag{}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should respect explicit library_id filters within accessible libraries", func() {
|
||||
tags := readAllTags(®ularUser, rest.QueryOptions{
|
||||
Filters: map[string]any{"library_id": libraryID2},
|
||||
Filters: map[string]interface{}{"library_id": libraryID2},
|
||||
})
|
||||
// Should see only tags from library 2: pop and rock(lib2)
|
||||
Expect(tags).To(HaveLen(2))
|
||||
@@ -174,7 +174,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should not return tags when filtering by inaccessible library", func() {
|
||||
tags := readAllTags(®ularUser, rest.QueryOptions{
|
||||
Filters: map[string]any{"library_id": libraryID3},
|
||||
Filters: map[string]interface{}{"library_id": libraryID3},
|
||||
})
|
||||
// Should return no tags since user can't access library 3
|
||||
Expect(tags).To(HaveLen(0))
|
||||
@@ -182,7 +182,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should filter by library 1 correctly", func() {
|
||||
tags := readAllTags(®ularUser, rest.QueryOptions{
|
||||
Filters: map[string]any{"library_id": libraryID1},
|
||||
Filters: map[string]interface{}{"library_id": libraryID1},
|
||||
})
|
||||
// Should see only rock from library 1
|
||||
Expect(tags).To(HaveLen(1))
|
||||
@@ -227,7 +227,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should allow headless processes to apply explicit library_id filters", func() {
|
||||
tags := readAllTags(nil, rest.QueryOptions{
|
||||
Filters: map[string]any{"library_id": libraryID3},
|
||||
Filters: map[string]interface{}{"library_id": libraryID3},
|
||||
})
|
||||
// Should see only jazz from library 3
|
||||
Expect(tags).To(HaveLen(1))
|
||||
@@ -243,7 +243,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should respect explicit library_id filters", func() {
|
||||
tags := readAllTags(&adminUser, rest.QueryOptions{
|
||||
Filters: map[string]any{"library_id": libraryID3},
|
||||
Filters: map[string]interface{}{"library_id": libraryID3},
|
||||
})
|
||||
// Should see only jazz from library 3
|
||||
Expect(tags).To(HaveLen(1))
|
||||
@@ -252,7 +252,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should filter by library 2 correctly", func() {
|
||||
tags := readAllTags(&adminUser, rest.QueryOptions{
|
||||
Filters: map[string]any{"library_id": libraryID2},
|
||||
Filters: map[string]interface{}{"library_id": libraryID2},
|
||||
})
|
||||
// Should see pop and rock from library 2
|
||||
Expect(tags).To(HaveLen(2))
|
||||
|
||||
@@ -234,7 +234,7 @@ var _ = Describe("TagRepository", func() {
|
||||
|
||||
It("should filter tags by partial value correctly", func() {
|
||||
options := rest.QueryOptions{
|
||||
Filters: map[string]any{"name": "%rock%"}, // Tags containing 'rock'
|
||||
Filters: map[string]interface{}{"name": "%rock%"}, // Tags containing 'rock'
|
||||
}
|
||||
result, err := restRepo.ReadAll(options)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -249,7 +249,7 @@ var _ = Describe("TagRepository", func() {
|
||||
|
||||
It("should filter tags by partial value using LIKE", func() {
|
||||
options := rest.QueryOptions{
|
||||
Filters: map[string]any{"name": "%e%"}, // Tags containing 'e'
|
||||
Filters: map[string]interface{}{"name": "%e%"}, // Tags containing 'e'
|
||||
}
|
||||
result, err := restRepo.ReadAll(options)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
@@ -264,7 +264,7 @@ var _ = Describe("TagRepository", func() {
|
||||
|
||||
It("should sort tags by value ascending", func() {
|
||||
options := rest.QueryOptions{
|
||||
Filters: map[string]any{"name": "%r%"}, // Tags containing 'r'
|
||||
Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r'
|
||||
Sort: "name",
|
||||
Order: "asc",
|
||||
}
|
||||
@@ -280,7 +280,7 @@ var _ = Describe("TagRepository", func() {
|
||||
|
||||
It("should sort tags by value descending", func() {
|
||||
options := rest.QueryOptions{
|
||||
Filters: map[string]any{"name": "%r%"}, // Tags containing 'r'
|
||||
Filters: map[string]interface{}{"name": "%r%"}, // Tags containing 'r'
|
||||
Sort: "name",
|
||||
Order: "desc",
|
||||
}
|
||||
|
||||
@@ -52,11 +52,11 @@ func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, erro
|
||||
return r.count(Select(), r.parseRestOptions(r.ctx, options...))
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Read(id string) (any, error) {
|
||||
func (r *transcodingRepository) Read(id string) (interface{}, error) {
|
||||
return r.Get(id)
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
|
||||
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
|
||||
res := model.Transcodings{}
|
||||
err := r.queryAll(sel, &res)
|
||||
@@ -67,11 +67,11 @@ func (r *transcodingRepository) EntityName() string {
|
||||
return "transcoding"
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) NewInstance() any {
|
||||
func (r *transcodingRepository) NewInstance() interface{} {
|
||||
return &model.Transcoding{}
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Save(entity any) (string, error) {
|
||||
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func (r *transcodingRepository) Save(entity any) (string, error) {
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (r *transcodingRepository) Update(id string, entity any, cols ...string) error {
|
||||
func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
|
||||
if !loggedUser(r.ctx).IsAdmin {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package plugins
|
||||
|
||||
import "slices"
|
||||
|
||||
// Capability represents a plugin capability type.
|
||||
// Capabilities are detected by checking which functions a plugin exports.
|
||||
type Capability string
|
||||
@@ -27,8 +25,11 @@ func detectCapabilities(plugin functionExistsChecker) []Capability {
|
||||
var capabilities []Capability
|
||||
|
||||
for cap, functions := range capabilityFunctions {
|
||||
if slices.ContainsFunc(functions, plugin.FunctionExists) {
|
||||
capabilities = append(capabilities, cap) // Found at least one function, plugin has this capability
|
||||
for _, fn := range functions {
|
||||
if plugin.FunctionExists(fn) {
|
||||
capabilities = append(capabilities, cap)
|
||||
break // Found at least one function, plugin has this capability
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,5 +38,10 @@ func detectCapabilities(plugin functionExistsChecker) []Capability {
|
||||
|
||||
// hasCapability checks if the given capabilities slice contains a specific capability.
|
||||
func hasCapability(capabilities []Capability, cap Capability) bool {
|
||||
return slices.Contains(capabilities, cap)
|
||||
for _, c := range capabilities {
|
||||
if c == cap {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -201,7 +200,9 @@ func (s *webSocketServiceImpl) CloseConnection(ctx context.Context, connectionID
|
||||
func (s *webSocketServiceImpl) Close() error {
|
||||
s.mu.Lock()
|
||||
connections := make(map[string]*wsConnection, len(s.connections))
|
||||
maps.Copy(connections, s.connections)
|
||||
for k, v := range s.connections {
|
||||
connections[k] = v
|
||||
}
|
||||
s.connections = make(map[string]*wsConnection)
|
||||
s.mu.Unlock()
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ func (ic *IgnoreChecker) PushAllParents(ctx context.Context, targetPath string)
|
||||
|
||||
// Load patterns for each parent directory
|
||||
currentPath := "."
|
||||
parts := strings.SplitSeq(path.Clean(targetPath), "/")
|
||||
for part := range parts {
|
||||
parts := strings.Split(path.Clean(targetPath), "/")
|
||||
for _, part := range parts {
|
||||
if part == "." || part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -215,8 +215,8 @@ func (t Tags) Lyrics() string {
|
||||
}
|
||||
|
||||
for tag, value := range t.Tags {
|
||||
if after, ok := strings.CutPrefix(tag, "lyrics-"); ok {
|
||||
language := strings.TrimSpace(after)
|
||||
if strings.HasPrefix(tag, "lyrics-") {
|
||||
language := strings.TrimSpace(strings.TrimPrefix(tag, "lyrics-"))
|
||||
|
||||
if language == "" {
|
||||
language = "xxx"
|
||||
|
||||
@@ -6,16 +6,16 @@ import (
|
||||
|
||||
type logger struct{}
|
||||
|
||||
func (l *logger) Info(msg string, keysAndValues ...any) {
|
||||
args := []any{
|
||||
func (l *logger) Info(msg string, keysAndValues ...interface{}) {
|
||||
args := []interface{}{
|
||||
"Scheduler: " + msg,
|
||||
}
|
||||
args = append(args, keysAndValues...)
|
||||
log.Debug(args...)
|
||||
}
|
||||
|
||||
func (l *logger) Error(err error, msg string, keysAndValues ...any) {
|
||||
args := []any{
|
||||
func (l *logger) Error(err error, msg string, keysAndValues ...interface{}) {
|
||||
args := []interface{}{
|
||||
"Scheduler: " + msg,
|
||||
}
|
||||
args = append(args, keysAndValues...)
|
||||
|
||||
@@ -68,8 +68,8 @@ func doLogin(ds model.DataStore, username string, password string, w http.Respon
|
||||
_ = rest.RespondWithJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func buildAuthPayload(user *model.User) map[string]any {
|
||||
payload := map[string]any{
|
||||
func buildAuthPayload(user *model.User) map[string]interface{} {
|
||||
payload := map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"name": user.Name,
|
||||
"username": user.UserName,
|
||||
@@ -288,7 +288,7 @@ func JWTRefresher(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]any {
|
||||
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
|
||||
username := UsernameFromConfig(r)
|
||||
if username == "" {
|
||||
username = UsernameFromExtAuthHeader(r)
|
||||
|
||||
@@ -53,7 +53,7 @@ var _ = Describe("Auth", func() {
|
||||
|
||||
It("returns the expected payload", func() {
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
var parsed map[string]any
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["isAdmin"]).To(Equal(true))
|
||||
Expect(parsed["username"]).To(Equal("johndoe"))
|
||||
@@ -88,7 +88,7 @@ var _ = Describe("Auth", func() {
|
||||
serveIndex(ds, fs, nil)(resp, req)
|
||||
|
||||
config := extractAppConfig(resp.Body.String())
|
||||
parsed := config["auth"].(map[string]any)
|
||||
parsed := config["auth"].(map[string]interface{})
|
||||
|
||||
Expect(parsed["id"]).To(Equal("111"))
|
||||
})
|
||||
@@ -106,7 +106,7 @@ var _ = Describe("Auth", func() {
|
||||
serveIndex(ds, fs, nil)(resp, req)
|
||||
|
||||
config := extractAppConfig(resp.Body.String())
|
||||
parsed := config["auth"].(map[string]any)
|
||||
parsed := config["auth"].(map[string]interface{})
|
||||
|
||||
Expect(parsed["id"]).To(Equal("111"))
|
||||
})
|
||||
@@ -127,7 +127,7 @@ var _ = Describe("Auth", func() {
|
||||
serveIndex(ds, fs, nil)(resp, req)
|
||||
|
||||
config := extractAppConfig(resp.Body.String())
|
||||
parsed := config["auth"].(map[string]any)
|
||||
parsed := config["auth"].(map[string]interface{})
|
||||
|
||||
Expect(parsed["username"]).To(Equal(newUser))
|
||||
})
|
||||
@@ -137,7 +137,7 @@ var _ = Describe("Auth", func() {
|
||||
serveIndex(ds, fs, nil)(resp, req)
|
||||
|
||||
config := extractAppConfig(resp.Body.String())
|
||||
parsed := config["auth"].(map[string]any)
|
||||
parsed := config["auth"].(map[string]interface{})
|
||||
|
||||
Expect(parsed["id"]).To(Equal("111"))
|
||||
Expect(parsed["isAdmin"]).To(BeFalse())
|
||||
@@ -182,7 +182,7 @@ var _ = Describe("Auth", func() {
|
||||
serveIndex(ds, fs, nil)(resp, req)
|
||||
|
||||
config := extractAppConfig(resp.Body.String())
|
||||
parsed := config["auth"].(map[string]any)
|
||||
parsed := config["auth"].(map[string]interface{})
|
||||
|
||||
Expect(parsed["id"]).To(Equal("111"))
|
||||
})
|
||||
@@ -206,7 +206,7 @@ var _ = Describe("Auth", func() {
|
||||
login(ds)(resp, req)
|
||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var parsed map[string]any
|
||||
var parsed map[string]interface{}
|
||||
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
|
||||
Expect(parsed["isAdmin"]).To(Equal(false))
|
||||
Expect(parsed["username"]).To(Equal("janedoe"))
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
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())
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,164 +0,0 @@
|
||||
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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,522 +0,0 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,186 +0,0 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,312 +0,0 @@
|
||||
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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
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(®ularUser)).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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
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(®ularUserWithPass)).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())
|
||||
})
|
||||
})
|
||||
@@ -1,158 +0,0 @@
|
||||
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())
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,143 +0,0 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
@@ -37,7 +37,7 @@ func requestLogger(next http.Handler) http.Handler {
|
||||
status := ww.Status()
|
||||
|
||||
message := fmt.Sprintf("HTTP: %s %s://%s%s", r.Method, scheme, r.Host, r.RequestURI)
|
||||
logArgs := []any{
|
||||
logArgs := []interface{}{
|
||||
r.Context(),
|
||||
message,
|
||||
"remoteAddr", r.RemoteAddr,
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -36,9 +35,9 @@ var sensitiveFieldsFullMask = []string{
|
||||
}
|
||||
|
||||
type configResponse struct {
|
||||
ID string `json:"id"`
|
||||
ConfigFile string `json:"configFile"`
|
||||
Config map[string]any `json:"config"`
|
||||
ID string `json:"id"`
|
||||
ConfigFile string `json:"configFile"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
func redactValue(key string, value string) string {
|
||||
@@ -48,8 +47,10 @@ func redactValue(key string, value string) string {
|
||||
}
|
||||
|
||||
// Check if this field should be fully masked
|
||||
if slices.Contains(sensitiveFieldsFullMask, key) {
|
||||
return "****"
|
||||
for _, field := range sensitiveFieldsFullMask {
|
||||
if field == key {
|
||||
return "****"
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this field should be partially masked
|
||||
@@ -68,7 +69,7 @@ func redactValue(key string, value string) string {
|
||||
}
|
||||
|
||||
// applySensitiveFieldMasking recursively applies masking to sensitive fields in the configuration map
|
||||
func applySensitiveFieldMasking(ctx context.Context, config map[string]any, prefix string) {
|
||||
func applySensitiveFieldMasking(ctx context.Context, config map[string]interface{}, prefix string) {
|
||||
for key, value := range config {
|
||||
fullKey := key
|
||||
if prefix != "" {
|
||||
@@ -76,7 +77,7 @@ func applySensitiveFieldMasking(ctx context.Context, config map[string]any, pref
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
case map[string]interface{}:
|
||||
// Recursively process nested maps
|
||||
applySensitiveFieldMasking(ctx, v, fullKey)
|
||||
case string:
|
||||
@@ -107,7 +108,7 @@ func getConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Unmarshal back to map to get the structure with proper field names
|
||||
var configMap map[string]any
|
||||
var configMap map[string]interface{}
|
||||
err = json.Unmarshal(configBytes, &configMap)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error unmarshaling config to map", err)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user