Compare commits

..

7 Commits

Author SHA1 Message Date
Deluan
dd78479a48 test(e2e): tests are fast, no need to skip on -short
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-08 09:57:53 -05:00
Deluan
290485a58f test(e2e): add tests for multi-library support and user access control
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-08 09:57:53 -05:00
Deluan
f6e1632d46 test(e2e): add tests for album sharing and user isolation scenarios
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-08 09:57:53 -05:00
Deluan
d52c08bb0f fix(e2e): improve database handling and snapshot restoration in tests
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-08 09:57:53 -05:00
Deluan
9ec46ce755 test(e2e): add comprehensive tests for Subsonic API endpoints
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-08 09:57:53 -05:00
Maximilian
a704e86ac1 refactor: run Go modernize (#5002) 2026-02-08 09:57:30 -05:00
Deluan
408aa78ed5 fix(scanner): log warning when metadata extraction fails
Added a warning log when the gotaglib extractor fails to read metadata
from a file. Previously, extraction errors were silently skipped, making
it difficult to diagnose issues with unreadable files during scanning.

Ref: https://github.com/navidrome/navidrome/issues/4604#issuecomment-3865690165
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-07 21:39:07 -05:00
117 changed files with 3114 additions and 352 deletions

View File

@@ -252,7 +252,7 @@ var _ = Describe("JWT Authentication", func() {
// Writer goroutine
wg.Go(func() {
for i := 0; i < 100; i++ {
for i := range 100 {
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 i := 0; i < 100; i++ {
for range 100 {
cache.get()
time.Sleep(1 * time.Millisecond)
}

View File

@@ -49,6 +49,7 @@ func (e extractor) Version() string {
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
f, close, err := e.openFile(filePath)
if err != nil {
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
return nil, err
}
defer close()
@@ -254,7 +255,7 @@ func parseTIPL(tags map[string][]string) {
}
var currentRole string
var currentValue []string
for _, part := range strings.Split(tipl[0], " ") {
for part := range strings.SplitSeq(tipl[0], " ") {
if _, ok := tiplMapping[part]; ok {
addRole(currentRole, currentValue)
currentRole = part

View File

@@ -65,7 +65,7 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
resp := map[string]any{
"apiKey": s.apiKey,
}
u, _ := request.UserFrom(r.Context())

View File

@@ -60,7 +60,7 @@ func (s *Router) routes() http.Handler {
}
func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{}
resp := map[string]any{}
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]interface{}{"status": resp.Valid, "user": resp.UserName})
_ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"status": resp.Valid, "user": resp.UserName})
}
func (s *Router) unlink(w http.ResponseWriter, r *http.Request) {

View File

@@ -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]interface{}
var parsed map[string]any
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]interface{}
var parsed map[string]any
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]interface{}
var parsed map[string]any
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["status"]).To(Equal(true))
Expect(parsed["user"]).To(Equal("ListenBrainzUser"))

View File

@@ -75,14 +75,14 @@ const (
type listenInfo struct {
ListenedAt int `json:"listened_at,omitempty"`
TrackMetadata trackMetadata `json:"track_metadata,omitempty"`
TrackMetadata trackMetadata `json:"track_metadata"`
}
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,omitempty"`
AdditionalInfo additionalInfo `json:"additional_info"`
}
type additionalInfo struct {

View File

@@ -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]interface{}{}
response := map[string]any{}
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 interface{}) error {
func (c *client) makeRequest(req *http.Request, response any) 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 {

View File

@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
@@ -433,7 +434,7 @@ func mapDeprecatedOption(legacyName, newName string) {
func parseIniFileConfiguration() {
cfgFile := viper.ConfigFileUsed()
if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" {
var iniConfig map[string]interface{}
var iniConfig map[string]any
err := viper.Unmarshal(&iniConfig)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
@@ -466,7 +467,7 @@ func disableExternalServices() {
}
func validatePlaylistsPath() error {
for _, path := range strings.Split(Server.PlaylistsPath, string(filepath.ListSeparator)) {
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
_, err := doublestar.Match(path, "")
if err != nil {
log.Error("Invalid PlaylistsPath", "path", path, err)
@@ -480,7 +481,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.Split(lang, ",") {
for l := range strings.SplitSeq(lang, ",") {
l = strings.TrimSpace(l)
if l != "" {
languages = append(languages, l)
@@ -494,13 +495,7 @@ func parseLanguages(lang string) []string {
func validatePurgeMissingOption() error {
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
valid := false
for _, v := range allowedValues {
if v == Server.Scanner.PurgeMissing {
valid = true
break
}
}
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
if !valid {
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error())

View File

@@ -365,7 +365,7 @@ var _ = Describe("Agents", func() {
})
type mockAgent struct {
Args []interface{}
Args []any
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 = []interface{}{id, name}
a.Args = []any{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 = []interface{}{id, name, mbid}
a.Args = []any{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 = []interface{}{id, name, mbid}
a.Args = []any{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 = []interface{}{id, name, mbid}
a.Args = []any{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 = []interface{}{id, name, mbid, limit}
a.Args = []any{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 = []interface{}{id, artistName, mbid, count}
a.Args = []any{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 = []interface{}{name, artist, mbid}
a.Args = []any{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 = []interface{}{id, name, artist, mbid, count}
a.Args = []any{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 = []interface{}{id, name, artist, mbid, count}
a.Args = []any{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 = []interface{}{id, name, mbid, count}
a.Args = []any{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 []interface{}
Args []any
}
func (t *testImageAgent) AgentName() string { return t.Name }
func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) {
t.Args = []interface{}{id, name, mbid}
t.Args = []any{id, name, mbid}
return t.Images, t.Err
}

View File

@@ -143,7 +143,7 @@ var _ = Describe("CacheWarmer", func() {
It("processes items in batches", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
for i := 0; i < 5; i++ {
for i := range 5 {
cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i)))
}

View File

@@ -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.Split(strings.ToLower(priority), ",") {
for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") {
pattern = strings.TrimSpace(pattern)
switch {
case pattern == "embedded":

View File

@@ -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.Split(strings.ToLower(priority), ",") {
for pattern := range strings.SplitSeq(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 i := 0; i < maxArtistFolderTraversalDepth; i++ {
for range maxArtistFolderTraversalDepth {
if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil {
return reader, path, nil
}

View File

@@ -4,6 +4,7 @@ import (
"cmp"
"context"
"crypto/sha256"
"maps"
"sync"
"time"
@@ -53,9 +54,7 @@ func createBaseClaims() map[string]any {
func CreatePublicToken(claims map[string]any) (string, error) {
tokenClaims := createBaseClaims()
for k, v := range claims {
tokenClaims[k] = v
}
maps.Copy(tokenClaims, claims)
_, token, err := TokenAuth.Encode(tokenClaims)
return token, err
@@ -66,9 +65,7 @@ func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, er
if !exp.IsZero() {
tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix()
}
for k, v := range claims {
tokenClaims[k] = v
}
maps.Copy(tokenClaims, claims)
_, token, err := TokenAuth.Encode(tokenClaims)
return token, err
@@ -100,7 +97,7 @@ func TouchToken(token jwt.Token) (string, error) {
return newToken, err
}
func Validate(tokenStr string) (map[string]interface{}, error) {
func Validate(tokenStr string) (map[string]any, error) {
token, err := jwtauth.VerifyToken(TokenAuth, tokenStr)
if err != nil {
return nil, err

View File

@@ -45,7 +45,7 @@ var _ = Describe("Auth", func() {
})
It("returns the claims from a valid JWT token", func() {
claims := map[string]interface{}{}
claims := map[string]any{}
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]interface{}{}
claims := map[string]any{}
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]interface{}{}
claims := map[string]any{}
claims["iss"] = "issuer"
claims["exp"] = yesterday.Unix()
token, _, err := auth.TokenAuth.Encode(claims)

View File

@@ -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([]interface{}, len(options))
argsSlice := make([]any, 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([]interface{}, len(options))
argsSlice := make([]any, 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([]interface{}, len(options))
argsSlice := make([]any, len(options))
for i, v := range options {
argsSlice[i] = v
}

View File

@@ -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 interface{}
var entity any
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 interface{}
var entity any
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return auxArtist{}, err

View File

@@ -159,7 +159,7 @@ type libraryRepositoryWrapper struct {
pluginManager PluginUnloader
}
func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
func (r *libraryRepositoryWrapper) Save(entity any) (string, error) {
lib := entity.(*model.Library)
if err := r.validateLibrary(lib); err != nil {
return "", err
@@ -191,7 +191,7 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
return strconv.Itoa(lib.ID), nil
}
func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error {
lib := entity.(*model.Library)
libID, err := strconv.Atoi(id)
if err != nil {

View File

@@ -196,9 +196,7 @@ 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.Add(1)
go func() {
defer s.wg.Done()
s.wg.Go(func() {
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)
@@ -214,7 +212,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.

View File

@@ -3,6 +3,7 @@ package playback
import (
"fmt"
"math/rand"
"strings"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
@@ -21,11 +22,11 @@ func NewQueue() *Queue {
}
func (pd *Queue) String() string {
filenames := ""
var filenames strings.Builder
for idx, item := range pd.Items {
filenames += fmt.Sprint(idx) + ":" + item.Path + " "
filenames.WriteString(fmt.Sprint(idx) + ":" + item.Path + " ")
}
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames)
return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames.String())
}
// returns the current mediafile or nil

View File

@@ -45,7 +45,7 @@ func InPlaylistsPath(folder model.Folder) bool {
return true
}
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
for path := range strings.SplitSeq(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 strings.HasPrefix(line, "file://") {
line = strings.TrimPrefix(line, "file://")
if after, ok := strings.CutPrefix(line, "file://"); ok {
line = after
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]interface{}{}
m := map[string]any{}
err := json.Unmarshal(data, &m)
if err != nil {
return err

View File

@@ -212,10 +212,7 @@ 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 := int(mf.Duration) - position
if remaining < 0 {
remaining = 0
}
remaining := max(int(mf.Duration)-position, 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)

View File

@@ -87,7 +87,7 @@ func (r *shareRepositoryWrapper) newId() (string, error) {
}
}
func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
s := entity.(*model.Share)
id, err := r.newId()
if err != nil {
@@ -127,7 +127,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
return id, err
}
func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error {
cols := []string{"description", "downloadable"}
// TODO Better handling of Share expiration

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io/fs"
"maps"
"net/url"
"path"
"testing/fstest"
@@ -135,9 +136,7 @@ func (ffs *FakeFS) UpdateTags(filePath string, newTags map[string]any, when ...t
if err != nil {
panic(err)
}
for k, v := range newTags {
tags[k] = v
}
maps.Copy(tags, newTags)
data, _ := json.Marshal(tags)
f.Data = data
ffs.Touch(filePath, when...)
@@ -180,9 +179,7 @@ func Track(num int, title string, tags ...map[string]any) map[string]any {
ts["title"] = title
ts["track"] = num
for _, t := range tags {
for k, v := range t {
ts[k] = v
}
maps.Copy(ts, t)
}
return ts
}
@@ -200,9 +197,7 @@ func MP3(tags ...map[string]any) *fstest.MapFile {
func File(tags ...map[string]any) *fstest.MapFile {
ts := map[string]any{}
for _, t := range tags {
for k, v := range t {
ts[k] = v
}
maps.Copy(ts, t)
}
modTime := time.Now()
if mt, ok := ts[fakeFileInfoModTime]; !ok {

View File

@@ -50,12 +50,12 @@ type userRepositoryWrapper struct {
}
// Save implements rest.Persistable by delegating to the underlying repository.
func (r *userRepositoryWrapper) Save(entity interface{}) (string, error) {
func (r *userRepositoryWrapper) Save(entity any) (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 interface{}, cols ...string) error {
func (r *userRepositoryWrapper) Update(id string, entity any, cols ...string) error {
return r.UserRepository.(rest.Persistable).Update(id, entity, cols...)
}

View File

@@ -126,7 +126,7 @@ func Optimize(ctx context.Context) {
}
log.Debug(ctx, "Optimizing open connections", "numConns", numConns)
var conns []*sql.Conn
for i := 0; i < numConns; i++ {
for range numConns {
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 ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) }
func (l *statusLogger) Printf(format string, v ...interface{}) {
func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) }
func (l *statusLogger) Printf(format string, v ...any) {
if len(v) < 1 {
return
}
@@ -183,27 +183,27 @@ type logAdapter struct {
silent bool
}
func (l *logAdapter) Fatal(v ...interface{}) {
func (l *logAdapter) Fatal(v ...any) {
log.Fatal(l.ctx, fmt.Sprint(v...))
}
func (l *logAdapter) Fatalf(format string, v ...interface{}) {
func (l *logAdapter) Fatalf(format string, v ...any) {
log.Fatal(l.ctx, fmt.Sprintf(format, v...))
}
func (l *logAdapter) Print(v ...interface{}) {
func (l *logAdapter) Print(v ...any) {
if !l.silent {
log.Info(l.ctx, fmt.Sprint(v...))
}
}
func (l *logAdapter) Println(v ...interface{}) {
func (l *logAdapter) Println(v ...any) {
if !l.silent {
log.Info(l.ctx, fmt.Sprintln(v...))
}
}
func (l *logAdapter) Printf(format string, v ...interface{}) {
func (l *logAdapter) Printf(format string, v ...any) {
if !l.silent {
log.Info(l.ctx, fmt.Sprintf(format, v...))
}

View File

@@ -19,7 +19,7 @@ import (
type Level uint32
type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{})
type LevelFunc = func(ctx any, msg any, keyValuePairs ...any)
var redacted = &Hook{
AcceptedLevels: logrus.AllLevels,
@@ -152,7 +152,7 @@ func Redact(msg string) string {
return r
}
func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context {
func NewContext(ctx context.Context, keyValuePairs ...any) context.Context {
if ctx == nil {
ctx = context.Background()
}
@@ -184,32 +184,32 @@ func IsGreaterOrEqualTo(level Level) bool {
return shouldLog(level, 2)
}
func Fatal(args ...interface{}) {
func Fatal(args ...any) {
Log(LevelFatal, args...)
os.Exit(1)
}
func Error(args ...interface{}) {
func Error(args ...any) {
Log(LevelError, args...)
}
func Warn(args ...interface{}) {
func Warn(args ...any) {
Log(LevelWarn, args...)
}
func Info(args ...interface{}) {
func Info(args ...any) {
Log(LevelInfo, args...)
}
func Debug(args ...interface{}) {
func Debug(args ...any) {
Log(LevelDebug, args...)
}
func Trace(args ...interface{}) {
func Trace(args ...any) {
Log(LevelTrace, args...)
}
func Log(level Level, args ...interface{}) {
func Log(level Level, args ...any) {
if !shouldLog(level, 3) {
return
}
@@ -250,7 +250,7 @@ func shouldLog(requiredLevel Level, skip int) bool {
return false
}
func parseArgs(args []interface{}) (*logrus.Entry, string) {
func parseArgs(args []any) (*logrus.Entry, string) {
var l *logrus.Entry
var err error
if args[0] == nil {
@@ -289,7 +289,7 @@ func parseArgs(args []interface{}) (*logrus.Entry, string) {
return l, ""
}
func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry {
func addFields(logger *logrus.Entry, keyValuePairs []any) *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 []interface{}) *logrus.Entry
return logger
}
func extractLogger(ctx interface{}) (*logrus.Entry, error) {
func extractLogger(ctx any) (*logrus.Entry, error) {
switch ctx := ctx.(type) {
case *logrus.Entry:
return ctx, nil

View File

@@ -41,7 +41,7 @@ type DataStore interface {
Scrobble(ctx context.Context) ScrobbleRepository
Plugin(ctx context.Context) PluginRepository
Resource(ctx context.Context, model interface{}) ResourceRepository
Resource(ctx context.Context, model any) ResourceRepository
WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(block func(tx DataStore) error, scope ...string) error

View File

@@ -5,7 +5,7 @@ import (
)
// TODO: Should the type be encoded in the ID?
func GetEntityByID(ctx context.Context, ds DataStore, id string) (interface{}, error) {
func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) {
ar, err := ds.Artist(ctx).Get(id)
if err == nil {
return ar, nil

View File

@@ -140,7 +140,7 @@ func (mf MediaFile) Hash() string {
}
hash, _ := hashstructure.Hash(mf, opts)
sum := md5.New()
sum.Write([]byte(fmt.Sprintf("%d", hash)))
sum.Write(fmt.Appendf(nil, "%d", hash))
sum.Write(mf.Tags.Hash())
sum.Write(mf.Participants.Hash())
return fmt.Sprintf("%x", sum.Sum(nil))

View File

@@ -268,8 +268,8 @@ func parseID3Pairs(name model.TagName, lowered model.Tags) []string {
prefix := string(name) + ":"
for tagKey, tagValues := range lowered {
keyStr := string(tagKey)
if strings.HasPrefix(keyStr, prefix) {
keyPart := strings.TrimPrefix(keyStr, prefix)
if after, ok := strings.CutPrefix(keyStr, prefix); ok {
keyPart := after
if keyPart == string(name) {
keyPart = ""
}

View File

@@ -49,8 +49,8 @@ func createGetPID(hash hashFunc) getPIDFunc {
}
getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
pid := ""
fields := strings.Split(spec, "|")
for _, field := range fields {
fields := strings.SplitSeq(spec, "|")
for field := range fields {
attributes := strings.Split(field, ",")
hasValue := false
values := slice.Map(attributes, func(attr string) string {

View File

@@ -51,13 +51,13 @@ func ParseTargets(libFolders []string) ([]ScanTarget, error) {
}
// Split by the first colon
colonIdx := strings.Index(part, ":")
if colonIdx == -1 {
before, after, ok := strings.Cut(part, ":")
if !ok {
return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part)
}
libIDStr := part[:colonIdx]
folderPath := part[colonIdx+1:]
libIDStr := before
folderPath := after
libID, err := strconv.Atoi(libIDStr)
if err != nil {

View File

@@ -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,omitempty"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
Tracks MediaFiles `structs:"-" json:"tracks,omitempty"`
Albums Albums `structs:"-" json:"albums,omitempty"`
URL string `structs:"-" json:"-"`

View File

@@ -144,10 +144,8 @@ func (t Tags) Merge(tags Tags) {
}
func (t Tags) Add(name TagName, v string) {
for _, existing := range t[name] {
if existing == v {
return
}
if slices.Contains(t[name], v) {
return
}
t[name] = append(t[name], v)
}

View File

@@ -145,11 +145,11 @@ func recentlyAddedSort() string {
return "created_at"
}
func recentlyPlayedFilter(string, interface{}) Sqlizer {
func recentlyPlayedFilter(string, any) Sqlizer {
return Gt{"play_count": 0}
}
func yearFilter(_ string, value interface{}) Sqlizer {
func yearFilter(_ string, value any) Sqlizer {
return Or{
And{
Gt{"min_year": 0},
@@ -160,14 +160,14 @@ func yearFilter(_ string, value interface{}) Sqlizer {
}
}
func artistFilter(_ string, value interface{}) Sqlizer {
func artistFilter(_ string, value any) Sqlizer {
return Or{
Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}),
Exists("json_tree(participants, '$.artist')", Eq{"value": value}),
}
}
func artistRoleFilter(name string, value interface{}) Sqlizer {
func artistRoleFilter(name string, value any) 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 interface{}) Sqlizer {
return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value})
}
func allRolesFilter(_ string, value interface{}) Sqlizer {
func allRolesFilter(_ string, value any) 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]interface{})
to := make(map[string]any)
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) (interface{}, error) {
func (r *albumRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -382,7 +382,7 @@ func (r *albumRepository) EntityName() string {
return "album"
}
func (r *albumRepository) NewInstance() interface{} {
func (r *albumRepository) NewInstance() any {
return &model.Album{}
}

View File

@@ -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 i := 0; i < playCount; i++ {
for range playCount {
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 i := 0; i < playCount; i++ {
for range playCount {
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([]interface{}{artistID}))
Expect(args).To(Equal([]any{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([]interface{}{"test-id"}))
Expect(args).To(Equal([]any{"test-id"}))
}
})

View File

@@ -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 interface{}) Sqlizer {
func artistLibraryIdFilter(_ string, value any) 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) (interface{}, error) {
func (r *artistRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (any, 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() interface{} {
func (r *artistRepository) NewInstance() any {
return &model.Artist{}
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
@@ -117,9 +118,7 @@ func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...
if err != nil {
return nil, err
}
for id, info := range batchResult {
result[id] = info
}
maps.Copy(result, batchResult)
}
return result, nil

View File

@@ -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) (interface{}, error) {
func (r *genreRepository) Read(id string) (any, 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) (interface{}, error) {
func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *genreRepository) NewInstance() interface{} {
func (r *genreRepository) NewInstance() any {
return &model.Genre{}
}

View File

@@ -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]interface{}{"name": "%rock%"},
Filters: map[string]any{"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]interface{}{"library_id": 2},
Filters: map[string]any{"library_id": 2},
})
Expect(err).ToNot(HaveOccurred())

View File

@@ -15,7 +15,7 @@ type PostMapper interface {
PostMapArgs(map[string]any) error
}
func toSQLArgs(rec interface{}) (map[string]interface{}, error) {
func toSQLArgs(rec any) (map[string]any, 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, []interface{}, error) {
func (e existsCond) ToSql() (string, []any, error) {
sql, args, err := e.cond.ToSql()
sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql)
if e.not {

View File

@@ -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) (interface{}, error) {
func (r *libraryRepository) Read(id string) (any, 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) (interface{}, error) {
return r.Get(idInt)
}
func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -322,11 +322,11 @@ func (r *libraryRepository) EntityName() string {
return "library"
}
func (r *libraryRepository) NewInstance() interface{} {
func (r *libraryRepository) NewInstance() any {
return &model.Library{}
}
func (r *libraryRepository) Save(entity interface{}) (string, error) {
func (r *libraryRepository) Save(entity any) (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 interface{}) (string, error) {
return strconv.Itoa(lib.ID), nil
}
func (r *libraryRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *libraryRepository) Update(id string, entity any, cols ...string) error {
lib := entity.(*model.Library)
idInt, err := strconv.Atoi(id)
if err != nil {

View File

@@ -443,11 +443,11 @@ func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error)
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *mediaFileRepository) Read(id string) (interface{}, error) {
func (r *mediaFileRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -455,7 +455,7 @@ func (r *mediaFileRepository) EntityName() string {
return "mediafile"
}
func (r *mediaFileRepository) NewInstance() interface{} {
func (r *mediaFileRepository) NewInstance() any {
return &model.MediaFile{}
}

View File

@@ -310,7 +310,7 @@ var _ = Describe("MediaRepository", func() {
// Update "Old Song": created long ago, updated recently
_, err := db.Update("media_file",
map[string]interface{}{
map[string]any{
"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]interface{}{
map[string]any{
"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]interface{}{
map[string]any{
"created_at": newTime,
"updated_at": oldTime,
},

View File

@@ -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 interface{}) model.ResourceRepository {
func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository {
switch m.(type) {
case model.User:
return s.User(ctx).(model.ResourceRepository)

View File

@@ -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) (interface{}, error) {
func (r *playerRepository) Read(id string) (any, 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) (interface{}, error) {
func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (any, 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() interface{} {
func (r *playerRepository) NewInstance() any {
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 interface{}) (string, error) {
func (r *playerRepository) Save(entity any) (string, error) {
t := entity.(*model.Player)
if !r.isPermitted(t) {
return "", rest.ErrPermissionDenied
@@ -142,7 +142,7 @@ func (r *playerRepository) Save(entity interface{}) (string, error) {
return id, err
}
func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *playerRepository) Update(id string, entity any, cols ...string) error {
t := entity.(*model.Player)
t.ID = id
if !r.isPermitted(t) {

View File

@@ -61,14 +61,14 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe
return r
}
func playlistFilter(_ string, value interface{}) Sqlizer {
func playlistFilter(_ string, value any) Sqlizer {
return Or{
substringFilter("playlist.name", value),
substringFilter("playlist.comment", value),
}
}
func smartPlaylistFilter(string, interface{}) Sqlizer {
func smartPlaylistFilter(string, any) Sqlizer {
return Or{
Eq{"rules": ""},
Eq{"rules": nil},
@@ -421,11 +421,11 @@ func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error)
return r.CountAll(r.parseRestOptions(r.ctx, options...))
}
func (r *playlistRepository) Read(id string) (interface{}, error) {
func (r *playlistRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
@@ -433,11 +433,11 @@ func (r *playlistRepository) EntityName() string {
return "playlist"
}
func (r *playlistRepository) NewInstance() interface{} {
func (r *playlistRepository) NewInstance() any {
return &model.Playlist{}
}
func (r *playlistRepository) Save(entity interface{}) (string, error) {
func (r *playlistRepository) Save(entity any) (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 +448,7 @@ func (r *playlistRepository) Save(entity interface{}) (string, error) {
return pls.ID, err
}
func (r *playlistRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *playlistRepository) Update(id string, entity any, cols ...string) error {
pls := dbPlaylist{Playlist: *entity.(*model.Playlist)}
current, err := r.Get(id)
if err != nil {

View File

@@ -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) (interface{}, error) {
func (r *playlistTrackRepository) Read(id string) (any, 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) (interface{}, error) {
func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (any, 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() interface{} {
func (r *playlistTrackRepository) NewInstance() any {
return &model.PlaylistTrack{}
}

View File

@@ -122,8 +122,8 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue {
UpdatedAt: pq.UpdatedAt,
}
if strings.TrimSpace(pq.Items) != "" {
tracks := strings.Split(pq.Items, ",")
for _, t := range tracks {
tracks := strings.SplitSeq(pq.Items, ",")
for t := range tracks {
q.Items = append(q.Items, model.MediaFile{ID: t})
}
}

View File

@@ -63,7 +63,7 @@ func (r *radioRepository) Put(radio *model.Radio) error {
return rest.ErrPermissionDenied
}
var values map[string]interface{}
var values map[string]any
radio.UpdatedAt = time.Now()
@@ -97,19 +97,19 @@ func (r *radioRepository) EntityName() string {
return "radio"
}
func (r *radioRepository) NewInstance() interface{} {
func (r *radioRepository) NewInstance() any {
return &model.Radio{}
}
func (r *radioRepository) Read(id string) (interface{}, error) {
func (r *radioRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
return r.GetAll(r.parseRestOptions(r.ctx, options...))
}
func (r *radioRepository) Save(entity interface{}) (string, error) {
func (r *radioRepository) Save(entity any) (string, error) {
t := entity.(*model.Radio)
if !r.isPermitted() {
return "", rest.ErrPermissionDenied
@@ -121,7 +121,7 @@ func (r *radioRepository) Save(entity interface{}) (string, error) {
return t.ID, err
}
func (r *radioRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *radioRepository) Update(id string, entity any, cols ...string) error {
t := entity.(*model.Radio)
t.ID = id
if !r.isPermitted() {

View File

@@ -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]interface{}{
ins := Insert(r.tableName).SetMap(map[string]any{
"id": id.NewRandom(),
"user_id": userId,
"service": service,

View File

@@ -24,7 +24,7 @@ var _ = Describe("ScrobbleBufferRepository", func() {
id := id.NewRandom()
ids = append(ids, id)
ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]interface{}{
ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]any{
"id": id,
"user_id": userId,
"service": service,

View File

@@ -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]interface{}{
values := map[string]any{
"media_file_id": mediaFileID,
"user_id": userID,
"submission_time": submissionTime.Unix(),

View File

@@ -138,7 +138,7 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles {
return sorted
}
func (r *shareRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *shareRepository) Update(id string, entity any, cols ...string) error {
s := entity.(*model.Share)
// TODO Validate record
s.ID = id
@@ -151,7 +151,7 @@ func (r *shareRepository) Update(id string, entity interface{}, cols ...string)
return err
}
func (r *shareRepository) Save(entity interface{}) (string, error) {
func (r *shareRepository) Save(entity any) (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() interface{} {
func (r *shareRepository) NewInstance() any {
return &model.Share{}
}
func (r *shareRepository) Read(id string) (interface{}, error) {
func (r *shareRepository) Read(id string) (any, 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) (interface{}, error) {
func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
sq := r.selectShare(r.parseRestOptions(r.ctx, options...))
res := model.Shares{}
err := r.queryAll(sq, &res)

View File

@@ -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]interface{}{
`).Bind(map[string]any{
"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]interface{}{
`).Bind(map[string]any{
"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]interface{}{
`).Bind(map[string]any{
"id": shareID,
"user": adminUser.ID,
"desc": "SQL Test Share",

View File

@@ -66,7 +66,7 @@ func (r sqlRepository) annId(itemID ...string) And {
}
}
func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error {
func (r sqlRepository) annUpsert(values map[string]any, 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]interface{}, itemIDs ...strin
func (r sqlRepository) SetStar(starred bool, ids ...string) error {
starredAt := time.Now()
return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...)
return r.annUpsert(map[string]any{"starred": starred, "starred_at": starredAt}, ids...)
}
func (r sqlRepository) SetRating(rating int, itemID string) error {
ratedAt := time.Now()
err := r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
err := r.annUpsert(map[string]any{"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]interface{}{}
values := map[string]any{}
values["user_id"] = userID
values["item_type"] = r.tableName
values["item_id"] = itemID

View File

@@ -32,17 +32,17 @@ var _ = Describe("Annotation Filters", func() {
Describe("annotationBoolFilter", func() {
DescribeTable("creates correct SQL expressions",
func(field, value string, expectedSQL string, expectedArgs []interface{}) {
func(field, value string, expectedSQL string, expectedArgs []any) {
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", []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)),
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)),
)
It("returns nil if value is not a string", func() {

View File

@@ -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 interface{}) Sqlizer {
func libraryIdFilter(_ string, value any) 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 interface{}) error {
func (r sqlRepository) queryOne(sq Sqlizer, response any) 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 interface{}, options ...model.QueryOptions) error {
func (r sqlRepository) queryAll(sq SelectBuilder, response any, 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 interface{}, options
}
// queryAllSlice is a helper function to query a single column and return the result in a slice
func (r sqlRepository) queryAllSlice(sq SelectBuilder, response interface{}) error {
func (r sqlRepository) queryAllSlice(sq SelectBuilder, response any) 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 interface{}, colsToUpdate ...string) (string, error) {
func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, 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 interface{}, cols
return r.put(res.ID, m, colsToUpdate...)
}
func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (newId string, err error) {
func (r sqlRepository) put(id string, m any, 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]interface{}{}
updateValues := map[string]any{}
// This is a map of the columns that need to be updated, if specified
c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) {

View File

@@ -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]interface{}{
values := map[string]any{
"comment": comment,
"position": position,
"updated_at": time.Now(),

View File

@@ -30,7 +30,7 @@ var _ = Describe("sqlRestful", func() {
r.filterMappings = map[string]filterFunc{
"name": fullTextFilter("table"),
}
options.Filters = map[string]interface{}{"name": "'"}
options.Filters = map[string]any{"name": "'"}
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
})
@@ -40,32 +40,32 @@ var _ = Describe("sqlRestful", func() {
return nil
},
}
options.Filters = map[string]interface{}{"name": "joe"}
options.Filters = map[string]any{"name": "joe"}
Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty())
})
It("returns a '=' condition for 'id' filter", func() {
options.Filters = map[string]interface{}{"id": "123"}
options.Filters = map[string]any{"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]interface{}{"id": []string{"123", "456"}}
options.Filters = map[string]any{"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]interface{}{"name": "joe"}
options.Filters = map[string]any{"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 interface{}) squirrel.Sqlizer {
"test": func(field string, value any) squirrel.Sqlizer {
return squirrel.Gt{field: value}
},
}
options.Filters = map[string]interface{}{"test": 100}
options.Filters = map[string]any{"test": 100}
Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}}))
})
})

View File

@@ -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 interface{}) Sqlizer {
func tagLibraryIdFilter(_ string, value any) 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) (interface{}, error) {
func (r *baseTagRepository) Read(id string) (any, 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) (interface{}, error) {
func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (any, 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() interface{} {
func (r *baseTagRepository) NewInstance() any {
return model.Tag{}
}

View File

@@ -165,7 +165,7 @@ var _ = Describe("Tag Library Filtering", func() {
It("should respect explicit library_id filters within accessible libraries", func() {
tags := readAllTags(&regularUser, rest.QueryOptions{
Filters: map[string]interface{}{"library_id": libraryID2},
Filters: map[string]any{"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(&regularUser, rest.QueryOptions{
Filters: map[string]interface{}{"library_id": libraryID3},
Filters: map[string]any{"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(&regularUser, rest.QueryOptions{
Filters: map[string]interface{}{"library_id": libraryID1},
Filters: map[string]any{"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]interface{}{"library_id": libraryID3},
Filters: map[string]any{"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]interface{}{"library_id": libraryID3},
Filters: map[string]any{"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]interface{}{"library_id": libraryID2},
Filters: map[string]any{"library_id": libraryID2},
})
// Should see pop and rock from library 2
Expect(tags).To(HaveLen(2))

View File

@@ -234,7 +234,7 @@ var _ = Describe("TagRepository", func() {
It("should filter tags by partial value correctly", func() {
options := rest.QueryOptions{
Filters: map[string]interface{}{"name": "%rock%"}, // Tags containing 'rock'
Filters: map[string]any{"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]interface{}{"name": "%e%"}, // Tags containing 'e'
Filters: map[string]any{"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]interface{}{"name": "%r%"}, // Tags containing 'r'
Filters: map[string]any{"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]interface{}{"name": "%r%"}, // Tags containing 'r'
Filters: map[string]any{"name": "%r%"}, // Tags containing 'r'
Sort: "name",
Order: "desc",
}

View File

@@ -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) (interface{}, error) {
func (r *transcodingRepository) Read(id string) (any, error) {
return r.Get(id)
}
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) {
func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, 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() interface{} {
func (r *transcodingRepository) NewInstance() any {
return &model.Transcoding{}
}
func (r *transcodingRepository) Save(entity interface{}) (string, error) {
func (r *transcodingRepository) Save(entity any) (string, error) {
if !loggedUser(r.ctx).IsAdmin {
return "", rest.ErrPermissionDenied
}
@@ -83,7 +83,7 @@ func (r *transcodingRepository) Save(entity interface{}) (string, error) {
return id, err
}
func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error {
func (r *transcodingRepository) Update(id string, entity any, cols ...string) error {
if !loggedUser(r.ctx).IsAdmin {
return rest.ErrPermissionDenied
}

View File

@@ -1,5 +1,7 @@
package plugins
import "slices"
// Capability represents a plugin capability type.
// Capabilities are detected by checking which functions a plugin exports.
type Capability string
@@ -25,11 +27,8 @@ func detectCapabilities(plugin functionExistsChecker) []Capability {
var capabilities []Capability
for cap, functions := range capabilityFunctions {
for _, fn := range functions {
if plugin.FunctionExists(fn) {
capabilities = append(capabilities, cap)
break // Found at least one function, plugin has this capability
}
if slices.ContainsFunc(functions, plugin.FunctionExists) {
capabilities = append(capabilities, cap) // Found at least one function, plugin has this capability
}
}
@@ -38,10 +37,5 @@ func detectCapabilities(plugin functionExistsChecker) []Capability {
// hasCapability checks if the given capabilities slice contains a specific capability.
func hasCapability(capabilities []Capability, cap Capability) bool {
for _, c := range capabilities {
if c == cap {
return true
}
}
return false
return slices.Contains(capabilities, cap)
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"maps"
"net/http"
"net/url"
"strings"
@@ -200,9 +201,7 @@ func (s *webSocketServiceImpl) CloseConnection(ctx context.Context, connectionID
func (s *webSocketServiceImpl) Close() error {
s.mu.Lock()
connections := make(map[string]*wsConnection, len(s.connections))
for k, v := range s.connections {
connections[k] = v
}
maps.Copy(connections, s.connections)
s.connections = make(map[string]*wsConnection)
s.mu.Unlock()

View File

@@ -65,8 +65,8 @@ func (ic *IgnoreChecker) PushAllParents(ctx context.Context, targetPath string)
// Load patterns for each parent directory
currentPath := "."
parts := strings.Split(path.Clean(targetPath), "/")
for _, part := range parts {
parts := strings.SplitSeq(path.Clean(targetPath), "/")
for part := range parts {
if part == "." || part == "" {
continue
}

View File

@@ -215,8 +215,8 @@ func (t Tags) Lyrics() string {
}
for tag, value := range t.Tags {
if strings.HasPrefix(tag, "lyrics-") {
language := strings.TrimSpace(strings.TrimPrefix(tag, "lyrics-"))
if after, ok := strings.CutPrefix(tag, "lyrics-"); ok {
language := strings.TrimSpace(after)
if language == "" {
language = "xxx"

View File

@@ -6,16 +6,16 @@ import (
type logger struct{}
func (l *logger) Info(msg string, keysAndValues ...interface{}) {
args := []interface{}{
func (l *logger) Info(msg string, keysAndValues ...any) {
args := []any{
"Scheduler: " + msg,
}
args = append(args, keysAndValues...)
log.Debug(args...)
}
func (l *logger) Error(err error, msg string, keysAndValues ...interface{}) {
args := []interface{}{
func (l *logger) Error(err error, msg string, keysAndValues ...any) {
args := []any{
"Scheduler: " + msg,
}
args = append(args, keysAndValues...)

View File

@@ -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]interface{} {
payload := map[string]interface{}{
func buildAuthPayload(user *model.User) map[string]any {
payload := map[string]any{
"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]interface{} {
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]any {
username := UsernameFromConfig(r)
if username == "" {
username = UsernameFromExtAuthHeader(r)

View File

@@ -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]interface{}
var parsed map[string]any
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]interface{})
parsed := config["auth"].(map[string]any)
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]interface{})
parsed := config["auth"].(map[string]any)
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]interface{})
parsed := config["auth"].(map[string]any)
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]interface{})
parsed := config["auth"].(map[string]any)
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]interface{})
parsed := config["auth"].(map[string]any)
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]interface{}
var parsed map[string]any
Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil())
Expect(parsed["isAdmin"]).To(Equal(false))
Expect(parsed["username"]).To(Equal("janedoe"))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 := []interface{}{
logArgs := []any{
r.Context(),
message,
"remoteAddr", r.RemoteAddr,

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"github.com/navidrome/navidrome/conf"
@@ -35,9 +36,9 @@ var sensitiveFieldsFullMask = []string{
}
type configResponse struct {
ID string `json:"id"`
ConfigFile string `json:"configFile"`
Config map[string]interface{} `json:"config"`
ID string `json:"id"`
ConfigFile string `json:"configFile"`
Config map[string]any `json:"config"`
}
func redactValue(key string, value string) string {
@@ -47,10 +48,8 @@ func redactValue(key string, value string) string {
}
// Check if this field should be fully masked
for _, field := range sensitiveFieldsFullMask {
if field == key {
return "****"
}
if slices.Contains(sensitiveFieldsFullMask, key) {
return "****"
}
// Check if this field should be partially masked
@@ -69,7 +68,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]interface{}, prefix string) {
func applySensitiveFieldMasking(ctx context.Context, config map[string]any, prefix string) {
for key, value := range config {
fullKey := key
if prefix != "" {
@@ -77,7 +76,7 @@ func applySensitiveFieldMasking(ctx context.Context, config map[string]interface
}
switch v := value.(type) {
case map[string]interface{}:
case map[string]any:
// Recursively process nested maps
applySensitiveFieldMasking(ctx, v, fullKey)
case string:
@@ -108,7 +107,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]interface{}
var configMap map[string]any
err = json.Unmarshal(configBytes, &configMap)
if err != nil {
log.Error(ctx, "Error unmarshaling config to map", err)

View File

@@ -93,12 +93,12 @@ var _ = Describe("Config API", func() {
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
// Check LastFM.ApiKey (partially masked)
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
lastfm, ok := resp.Config["LastFM"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(lastfm["ApiKey"]).To(Equal("s*************3"))
// Check Spotify.Secret (partially masked)
spotify, ok := resp.Config["Spotify"].(map[string]interface{})
spotify, ok := resp.Config["Spotify"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(spotify["Secret"]).To(Equal("s**************6"))
@@ -109,7 +109,7 @@ var _ = Describe("Config API", func() {
Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****"))
// Check Prometheus.Password (fully masked)
prometheus, ok := resp.Config["Prometheus"].(map[string]interface{})
prometheus, ok := resp.Config["Prometheus"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(prometheus["Password"]).To(Equal("****"))
})
@@ -128,7 +128,7 @@ var _ = Describe("Config API", func() {
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
// Check LastFM.ApiKey - should be preserved because it's sensitive
lastfm, ok := resp.Config["LastFM"].(map[string]interface{})
lastfm, ok := resp.Config["LastFM"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(lastfm["ApiKey"]).To(Equal(""))

View File

@@ -95,7 +95,7 @@ func (api *Router) routes() http.Handler {
return r
}
func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
func (api *Router) R(r chi.Router, pathPrefix string, model any, persistable bool) {
constructor := func(ctx context.Context) rest.Repository {
return api.ds.Resource(ctx, model)
}

View File

@@ -218,7 +218,7 @@ func reorderItem(ds model.DataStore) http.HandlerFunc {
return
}
_, err = w.Write([]byte(fmt.Sprintf(`{"id":"%d"}`, id)))
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}

View File

@@ -28,7 +28,7 @@ func newTranslationRepository(context.Context) rest.Repository {
type translationRepository struct{}
func (r *translationRepository) Read(id string) (interface{}, error) {
func (r *translationRepository) Read(id string) (any, error) {
translations, _ := loadTranslations()
if t, ok := translations[id]; ok {
return t, nil
@@ -43,7 +43,7 @@ func (r *translationRepository) Count(...rest.QueryOptions) (int64, error) {
}
// ReadAll simple implementation, only returns IDs. Does not support any `options`
func (r *translationRepository) ReadAll(...rest.QueryOptions) (interface{}, error) {
func (r *translationRepository) ReadAll(...rest.QueryOptions) (any, error) {
translations, _ := loadTranslations()
var result []translation
for _, t := range translations {
@@ -57,7 +57,7 @@ func (r *translationRepository) EntityName() string {
return "translation"
}
func (r *translationRepository) NewInstance() interface{} {
func (r *translationRepository) NewInstance() any {
return &translation{}
}
@@ -103,7 +103,7 @@ func loadTranslation(fsys fs.FS, fileName string) (translation translation, err
if err != nil {
return
}
var out map[string]interface{}
var out map[string]any
if err = json.Unmarshal(data, &out); err != nil {
return
}

View File

@@ -24,7 +24,7 @@ var _ = Describe("Translations", func() {
filePath := filepath.Join(consts.I18nFolder, name)
file, _ := fsys.Open(filePath)
data, _ := io.ReadAll(file)
var out map[string]interface{}
var out map[string]any
Expect(filepath.Ext(filePath)).To(Equal(".json"), filePath)
Expect(json.Unmarshal(data, &out)).To(BeNil(), filePath)
@@ -40,7 +40,7 @@ var _ = Describe("Translations", func() {
Expect(err).To(BeNil())
Expect(tr.ID).To(Equal("en"))
Expect(tr.Name).To(Equal("English"))
var out map[string]interface{}
var out map[string]any
Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil())
})
})

View File

@@ -39,7 +39,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
http.NotFound(w, r)
return
}
appConfig := map[string]interface{}{
appConfig := map[string]any{
"version": consts.Version,
"firstTime": firstTime,
"variousArtistsId": consts.VariousArtistsID,
@@ -95,7 +95,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
if version != "dev" {
version = "v" + version
}
data := map[string]interface{}{
data := map[string]any{
"AppConfig": string(appConfigJson),
"Version": version,
}
@@ -145,7 +145,7 @@ type shareTrack struct {
Duration float32 `json:"duration,omitempty"`
}
func addShareData(r *http.Request, data map[string]interface{}, shareInfo *model.Share) {
func addShareData(r *http.Request, data map[string]any, shareInfo *model.Share) {
ctx := r.Context()
if shareInfo == nil || shareInfo.ID == "" {
return

View File

@@ -80,8 +80,8 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
// Create a listener based on the address type (either Unix socket or TCP)
var listener net.Listener
var err error
if strings.HasPrefix(addr, "unix:") {
socketPath := strings.TrimPrefix(addr, "unix:")
if after, ok := strings.CutPrefix(addr, "unix:"); ok {
socketPath := after
listener, err = createUnixSocketFile(socketPath, conf.Server.UnixSocketPerm)
if err != nil {
return err

View File

@@ -319,7 +319,7 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
callback, _ := p.String("callback")
wrapper := &responses.JsonWrapper{Subsonic: *payload}
response, err = json.Marshal(wrapper)
response = []byte(fmt.Sprintf("%s(%s)", callback, response))
response = fmt.Appendf(nil, "%s(%s)", callback, response)
default:
w.Header().Set("Content-Type", "application/xml")
response, err = xml.Marshal(payload)

View File

@@ -34,10 +34,10 @@ func newResponse() *responses.Subsonic {
type subError struct {
code int32
messages []interface{}
messages []any
}
func newError(code int32, message ...interface{}) error {
func newError(code int32, message ...any) error {
return subError{
code: code,
messages: message,
@@ -176,8 +176,8 @@ func isClientInList(clientList, client string) bool {
if clientList == "" || client == "" {
return false
}
clients := strings.Split(clientList, ",")
for _, c := range clients {
clients := strings.SplitSeq(clientList, ",")
for c := range clients {
if strings.TrimSpace(c) == client {
return true
}

View File

@@ -5,6 +5,7 @@ import (
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
@@ -120,12 +121,12 @@ func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) {
lyricsResponse.Artist = artist
lyricsResponse.Title = title
lyricsText := ""
var lyricsText strings.Builder
for _, line := range structuredLyrics[0].Line {
lyricsText += line.Value + "\n"
lyricsText.WriteString(line.Value + "\n")
}
lyricsResponse.Value = lyricsText
lyricsResponse.Value = lyricsText.String()
return response, nil
}

View File

@@ -459,7 +459,7 @@ type PlayQueueByIndex struct {
}
type Bookmark struct {
Entry Child `xml:"entry,omitempty" json:"entry,omitempty"`
Entry Child `xml:"entry,omitempty" json:"entry"`
Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
Username string `xml:"username,attr" json:"username"`
Comment string `xml:"comment,attr" json:"comment"`

View File

@@ -26,17 +26,17 @@ type snapshotMatcher struct {
c *cupaloy.Config
}
func (matcher snapshotMatcher) Match(actual interface{}) (success bool, err error) {
func (matcher snapshotMatcher) Match(actual any) (success bool, err error) {
actualJson := strings.TrimSpace(string(actual.([]byte)))
err = matcher.c.SnapshotWithName(ginkgo.CurrentSpecReport().FullText(), actualJson)
success = err == nil
return
}
func (matcher snapshotMatcher) FailureMessage(_ interface{}) (message string) {
func (matcher snapshotMatcher) FailureMessage(_ any) (message string) {
return "Expected to match saved snapshot\n"
}
func (matcher snapshotMatcher) NegatedFailureMessage(_ interface{}) (message string) {
func (matcher snapshotMatcher) NegatedFailureMessage(_ any) (message string) {
return "Expected to not match saved snapshot\n"
}

Some files were not shown because too many files have changed in this diff Show More