mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-08 14:01:10 -05:00
Compare commits
7 Commits
claude/cre
...
subsonic-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd78479a48 | ||
|
|
290485a58f | ||
|
|
f6e1632d46 | ||
|
|
d52c08bb0f | ||
|
|
9ec46ce755 | ||
|
|
a704e86ac1 | ||
|
|
408aa78ed5 |
52
.github/workflows/create-release.yml
vendored
52
.github/workflows/create-release.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: Create Release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release version (e.g. 0.53.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version format
|
||||
run: |
|
||||
if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
||||
echo "::error::Invalid version format '${{ inputs.version }}'. Expected X.X.X"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check if tag already exists
|
||||
run: |
|
||||
if git rev-parse "v${{ inputs.version }}" >/dev/null 2>&1; then
|
||||
echo "::error::Tag v${{ inputs.version }} already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run go mod tidy
|
||||
run: go mod tidy
|
||||
|
||||
- name: Check for pending changes
|
||||
run: |
|
||||
if [ -n "$(git status -s)" ]; then
|
||||
echo "::error::There are pending changes after 'go mod tidy'. Please commit them first."
|
||||
git status -s
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
git tag v${{ inputs.version }}
|
||||
git push origin v${{ inputs.version }}
|
||||
6
Makefile
6
Makefile
@@ -242,7 +242,11 @@ clean:
|
||||
|
||||
release:
|
||||
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
|
||||
gh workflow run create-release.yml -f version=${V}
|
||||
go mod tidy
|
||||
@if [ -n "`git status -s`" ]; then echo "\n\nThere are pending changes. Please commit or stash first"; exit 1; fi
|
||||
make pre-push
|
||||
git tag v${V}
|
||||
git push origin v${V} --no-verify
|
||||
.PHONY: release
|
||||
|
||||
download-deps:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
6
core/external/extdata_helper_test.go
vendored
6
core/external/extdata_helper_test.go
vendored
@@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||
|
||||
// GetAll implements model.ArtistRepository.
|
||||
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
||||
argsSlice := make([]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
|
||||
}
|
||||
|
||||
4
core/external/provider.go
vendored
4
core/external/provider.go
vendored
@@ -93,7 +93,7 @@ func NewProvider(ds model.DataStore, agents Agents) Provider {
|
||||
}
|
||||
|
||||
func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
|
||||
var entity 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
|
||||
16
db/db.go
16
db/db.go
@@ -126,7 +126,7 @@ func Optimize(ctx context.Context) {
|
||||
}
|
||||
log.Debug(ctx, "Optimizing open connections", "numConns", numConns)
|
||||
var conns []*sql.Conn
|
||||
for 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...))
|
||||
}
|
||||
|
||||
24
log/log.go
24
log/log.go
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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{}) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ var _ = Describe("Tag Library Filtering", func() {
|
||||
|
||||
It("should respect explicit library_id filters within accessible libraries", func() {
|
||||
tags := readAllTags(®ularUser, rest.QueryOptions{
|
||||
Filters: map[string]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(®ularUser, 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(®ularUser, 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))
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
354
server/e2e/e2e_suite_test.go
Normal file
354
server/e2e/e2e_suite_test.go
Normal 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())
|
||||
}
|
||||
350
server/e2e/subsonic_album_lists_test.go
Normal file
350
server/e2e/subsonic_album_lists_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
164
server/e2e/subsonic_bookmarks_test.go
Normal file
164
server/e2e/subsonic_bookmarks_test.go
Normal 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)))
|
||||
})
|
||||
})
|
||||
})
|
||||
522
server/e2e/subsonic_browsing_test.go
Normal file
522
server/e2e/subsonic_browsing_test.go
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
186
server/e2e/subsonic_media_annotation_test.go
Normal file
186
server/e2e/subsonic_media_annotation_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
79
server/e2e/subsonic_media_retrieval_test.go
Normal file
79
server/e2e/subsonic_media_retrieval_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
312
server/e2e/subsonic_multilibrary_test.go
Normal file
312
server/e2e/subsonic_multilibrary_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
97
server/e2e/subsonic_multiuser_test.go
Normal file
97
server/e2e/subsonic_multiuser_test.go
Normal 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(®ularUser)).To(Succeed())
|
||||
Expect(ds.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
loadedUser, err := ds.User(ctx).FindByUsername(regularUser.UserName)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
regularUser.Libraries = loadedUser.Libraries
|
||||
})
|
||||
|
||||
Describe("Admin-only endpoint restrictions", func() {
|
||||
It("startScan fails for regular user", func() {
|
||||
r := newReqWithUser(regularUser, "startScan")
|
||||
_, err := router.StartScan(r)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not authorized"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Browsing as regular user", func() {
|
||||
It("regular user can browse the library", func() {
|
||||
r := newReqWithUser(regularUser, "getArtists")
|
||||
resp, err := router.GetArtists(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Artist).ToNot(BeNil())
|
||||
Expect(resp.Artist.Index).ToNot(BeEmpty())
|
||||
})
|
||||
|
||||
It("regular user can search", func() {
|
||||
r := newReqWithUser(regularUser, "search3", "query", "Beatles")
|
||||
resp, err := router.Search3(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.SearchResult3).ToNot(BeNil())
|
||||
Expect(resp.SearchResult3.Artist).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getUser authorization", func() {
|
||||
It("regular user can get their own info", func() {
|
||||
r := newReqWithUser(regularUser, "getUser", "username", "regular")
|
||||
resp, err := router.GetUser(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.User.Username).To(Equal("regular"))
|
||||
Expect(resp.User.AdminRole).To(BeFalse())
|
||||
})
|
||||
|
||||
It("regular user cannot get another user's info", func() {
|
||||
r := newReqWithUser(regularUser, "getUser", "username", "admin")
|
||||
_, err := router.GetUser(r)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not authorized"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("getUsers for regular user", func() {
|
||||
It("returns only the requesting user's info", func() {
|
||||
r := newReqWithUser(regularUser, "getUsers")
|
||||
resp, err := router.GetUsers(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.Users).ToNot(BeNil())
|
||||
Expect(resp.Users.User).To(HaveLen(1))
|
||||
Expect(resp.Users.User[0].Username).To(Equal("regular"))
|
||||
Expect(resp.Users.User[0].AdminRole).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
130
server/e2e/subsonic_playlists_test.go
Normal file
130
server/e2e/subsonic_playlists_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
94
server/e2e/subsonic_radio_test.go
Normal file
94
server/e2e/subsonic_radio_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
60
server/e2e/subsonic_scan_test.go
Normal file
60
server/e2e/subsonic_scan_test.go
Normal 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(®ularUserWithPass)).To(Succeed())
|
||||
Expect(ds.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed())
|
||||
|
||||
// Reload user with libraries
|
||||
loadedUser, err := ds.User(ctx).FindByUsername(regularUser.UserName)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
regularUser.Libraries = loadedUser.Libraries
|
||||
|
||||
r := newReqWithUser(regularUser, "startScan")
|
||||
_, err = router.StartScan(r)
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not authorized"))
|
||||
})
|
||||
|
||||
It("startScan returns scan status response", func() {
|
||||
r := newReq("startScan")
|
||||
resp, err := router.StartScan(r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Status).To(Equal(responses.StatusOK))
|
||||
Expect(resp.ScanStatus).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
158
server/e2e/subsonic_searching_test.go
Normal file
158
server/e2e/subsonic_searching_test.go
Normal 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())
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
143
server/e2e/subsonic_sharing_test.go
Normal file
143
server/e2e/subsonic_sharing_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
86
server/e2e/subsonic_system_test.go
Normal file
86
server/e2e/subsonic_system_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
56
server/e2e/subsonic_users_test.go
Normal file
56
server/e2e/subsonic_users_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(""))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user