mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-18 23:25:30 -05:00
Compare commits
1 Commits
custom-col
...
claude/api
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68591dab06 |
2
Makefile
2
Makefile
@@ -20,7 +20,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||
CROSS_TAGLIB_VERSION ?= 2.1.1-2
|
||||
GOLANGCI_LINT_VERSION ?= v2.10.0
|
||||
GOLANGCI_LINT_VERSION ?= v2.9.0
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
type authResponse struct {
|
||||
JWT string `json:"jwt"` //nolint:gosec
|
||||
JWT string `json:"jwt"`
|
||||
}
|
||||
|
||||
var result authResponse
|
||||
|
||||
@@ -110,7 +110,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec
|
||||
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx)))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ type listenBrainzResponse struct {
|
||||
}
|
||||
|
||||
type listenBrainzRequest struct {
|
||||
ApiKey string //nolint:gosec
|
||||
ApiKey string
|
||||
Body listenBrainzRequestBody
|
||||
}
|
||||
|
||||
|
||||
@@ -172,8 +172,8 @@ type TagConf struct {
|
||||
|
||||
type lastfmOptions struct {
|
||||
Enabled bool
|
||||
ApiKey string //nolint:gosec
|
||||
Secret string //nolint:gosec
|
||||
ApiKey string
|
||||
Secret string
|
||||
Language string
|
||||
ScrobbleFirstArtistOnly bool
|
||||
|
||||
@@ -183,7 +183,7 @@ type lastfmOptions struct {
|
||||
|
||||
type spotifyOptions struct {
|
||||
ID string
|
||||
Secret string //nolint:gosec
|
||||
Secret string
|
||||
}
|
||||
|
||||
type deezerOptions struct {
|
||||
@@ -208,7 +208,7 @@ type httpHeaderOptions struct {
|
||||
type prometheusOptions struct {
|
||||
Enabled bool
|
||||
MetricsPath string
|
||||
Password string //nolint:gosec
|
||||
Password string
|
||||
}
|
||||
|
||||
type AudioDeviceDefinition []string
|
||||
@@ -748,7 +748,7 @@ func getConfigFile(cfgFile string) string {
|
||||
}
|
||||
cfgFile = os.Getenv("ND_CONFIGFILE")
|
||||
if cfgFile != "" {
|
||||
if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec
|
||||
if _, err := os.Stat(cfgFile); err == nil {
|
||||
return cfgFile
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err
|
||||
hc := http.Client{Timeout: 5 * time.Second}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil)
|
||||
req.Header.Set("User-Agent", consts.HTTPUserAgent)
|
||||
resp, err := hc.Do(req) //nolint:gosec
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := hc.Do(req) //nolint:gosec
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Could not send Insights data", err)
|
||||
return
|
||||
|
||||
@@ -44,7 +44,7 @@ func newLocalStorage(u url.URL) storage.Storage {
|
||||
|
||||
func (s *localStorage) FS() (storage.MusicFS, error) {
|
||||
path := s.u.Path
|
||||
if _, err := os.Stat(path); err != nil { //nolint:gosec
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", err, path)
|
||||
}
|
||||
return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil
|
||||
|
||||
9
db/db.go
9
db/db.go
@@ -6,9 +6,7 @@ import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/maruel/natural"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
_ "github.com/navidrome/navidrome/db/migrations"
|
||||
@@ -33,12 +31,7 @@ func Db() *sql.DB {
|
||||
return singleton.GetInstance(func() *sql.DB {
|
||||
sql.Register(Driver, &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.RegisterCollation("NATURALSORT", func(a, b string) int {
|
||||
return natural.Compare(strings.ToLower(a), strings.ToLower(b))
|
||||
})
|
||||
return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false)
|
||||
},
|
||||
})
|
||||
Path = conf.Server.DbPath
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Change order_*/sort_* column collation from NOCASE to NATURALSORT.
|
||||
-- This way bare ORDER BY on these columns automatically uses natural sorting,
|
||||
-- without needing explicit COLLATE NATURALSORT in every query.
|
||||
PRAGMA writable_schema = ON;
|
||||
UPDATE sqlite_master
|
||||
SET sql = replace(sql, 'collate NOCASE', 'collate NATURALSORT')
|
||||
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
|
||||
PRAGMA writable_schema = OFF;
|
||||
|
||||
-- Recreate indexes on order_* and sort expression fields to use NATURALSORT collation.
|
||||
-- This enables natural number ordering (e.g., "Album 2" before "Album 10").
|
||||
|
||||
-- Artist indexes
|
||||
drop index if exists artist_order_artist_name;
|
||||
create index artist_order_artist_name
|
||||
on artist (order_artist_name collate NATURALSORT);
|
||||
|
||||
drop index if exists artist_sort_name;
|
||||
create index artist_sort_name
|
||||
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
|
||||
|
||||
-- Album indexes
|
||||
drop index if exists album_order_album_name;
|
||||
create index album_order_album_name
|
||||
on album (order_album_name collate NATURALSORT);
|
||||
|
||||
drop index if exists album_order_album_artist_name;
|
||||
create index album_order_album_artist_name
|
||||
on album (order_album_artist_name collate NATURALSORT);
|
||||
|
||||
drop index if exists album_alphabetical_by_artist;
|
||||
create index album_alphabetical_by_artist
|
||||
on album (compilation, order_album_artist_name collate NATURALSORT, order_album_name collate NATURALSORT);
|
||||
|
||||
drop index if exists album_sort_name;
|
||||
create index album_sort_name
|
||||
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
|
||||
|
||||
drop index if exists album_sort_album_artist_name;
|
||||
create index album_sort_album_artist_name
|
||||
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT);
|
||||
|
||||
-- Media file indexes
|
||||
drop index if exists media_file_order_title;
|
||||
create index media_file_order_title
|
||||
on media_file (order_title collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_order_album_name;
|
||||
create index media_file_order_album_name
|
||||
on media_file (order_album_name collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_order_artist_name;
|
||||
create index media_file_order_artist_name
|
||||
on media_file (order_artist_name collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_sort_title;
|
||||
create index media_file_sort_title
|
||||
on media_file (coalesce(nullif(sort_title,''),order_title) collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_sort_artist_name;
|
||||
create index media_file_sort_artist_name
|
||||
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT);
|
||||
|
||||
drop index if exists media_file_sort_album_name;
|
||||
create index media_file_sort_album_name
|
||||
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT);
|
||||
|
||||
-- Playlist and radio indexes: recreate to match new NATURALSORT column collation
|
||||
drop index if exists playlist_name;
|
||||
create index playlist_name
|
||||
on playlist (name collate NATURALSORT);
|
||||
|
||||
drop index if exists radio_name;
|
||||
create index radio_name
|
||||
on radio (name collate NATURALSORT);
|
||||
|
||||
-- +goose Down
|
||||
|
||||
-- Restore NOCASE column collation
|
||||
PRAGMA writable_schema = ON;
|
||||
UPDATE sqlite_master
|
||||
SET sql = replace(sql, 'collate NATURALSORT', 'collate NOCASE')
|
||||
WHERE type = 'table' AND name IN ('artist', 'album', 'media_file', 'playlist', 'radio');
|
||||
PRAGMA writable_schema = OFF;
|
||||
|
||||
-- Restore NOCASE collation indexes
|
||||
|
||||
-- Artist indexes
|
||||
drop index if exists artist_order_artist_name;
|
||||
create index artist_order_artist_name
|
||||
on artist (order_artist_name);
|
||||
|
||||
drop index if exists artist_sort_name;
|
||||
create index artist_sort_name
|
||||
on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||
|
||||
-- Album indexes
|
||||
drop index if exists album_order_album_name;
|
||||
create index album_order_album_name
|
||||
on album (order_album_name);
|
||||
|
||||
drop index if exists album_order_album_artist_name;
|
||||
create index album_order_album_artist_name
|
||||
on album (order_album_artist_name);
|
||||
|
||||
drop index if exists album_alphabetical_by_artist;
|
||||
create index album_alphabetical_by_artist
|
||||
on album (compilation, order_album_artist_name, order_album_name);
|
||||
|
||||
drop index if exists album_sort_name;
|
||||
create index album_sort_name
|
||||
on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||
|
||||
drop index if exists album_sort_album_artist_name;
|
||||
create index album_sort_album_artist_name
|
||||
on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NOCASE);
|
||||
|
||||
-- Media file indexes
|
||||
drop index if exists media_file_order_title;
|
||||
create index media_file_order_title
|
||||
on media_file (order_title);
|
||||
|
||||
drop index if exists media_file_order_album_name;
|
||||
create index media_file_order_album_name
|
||||
on media_file (order_album_name);
|
||||
|
||||
drop index if exists media_file_order_artist_name;
|
||||
create index media_file_order_artist_name
|
||||
on media_file (order_artist_name);
|
||||
|
||||
drop index if exists media_file_sort_title;
|
||||
create index media_file_sort_title
|
||||
on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE);
|
||||
|
||||
drop index if exists media_file_sort_artist_name;
|
||||
create index media_file_sort_artist_name
|
||||
on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE);
|
||||
|
||||
drop index if exists media_file_sort_album_name;
|
||||
create index media_file_sort_album_name
|
||||
on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE);
|
||||
|
||||
-- Restore playlist and radio indexes
|
||||
drop index if exists playlist_name;
|
||||
create index playlist_name
|
||||
on playlist (name);
|
||||
|
||||
drop index if exists radio_name;
|
||||
create index radio_name
|
||||
on radio (name);
|
||||
4
go.mod
4
go.mod
@@ -46,13 +46,13 @@ require (
|
||||
github.com/lestrrat-go/jwx/v2 v2.1.6
|
||||
github.com/maruel/natural v1.3.0
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/onsi/ginkgo/v2 v2.28.1
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/pelletier/go-toml/v2 v2.2.4
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/dbx v1.11.0
|
||||
github.com/pressly/goose/v3 v3.26.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rjeczalik/notify v0.9.3
|
||||
|
||||
8
go.sum
8
go.sum
@@ -179,8 +179,8 @@ github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
@@ -210,8 +210,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
|
||||
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
|
||||
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
|
||||
@@ -38,7 +38,7 @@ type MediaFile struct {
|
||||
AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead
|
||||
// AlbumArtist is the display name used for the album artist.
|
||||
AlbumArtist string `structs:"album_artist" json:"albumArtist"`
|
||||
AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"`
|
||||
AlbumID string `structs:"album_id" json:"albumId"`
|
||||
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
|
||||
TrackNumber int `structs:"track_number" json:"trackNumber"`
|
||||
DiscNumber int `structs:"disc_number" json:"discNumber"`
|
||||
|
||||
@@ -22,7 +22,7 @@ type User struct {
|
||||
Password string `structs:"-" json:"-"`
|
||||
// This is used to set or change a password when calling Put. If it is empty, the password is not changed.
|
||||
// It is received from the UI with the name "password"
|
||||
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec
|
||||
NewPassword string `structs:"password,omitempty" json:"password,omitempty"`
|
||||
// If changing the password, this is also required
|
||||
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
"missing": booleanFilter,
|
||||
"library_id": artistLibraryIdFilter,
|
||||
})
|
||||
r.setSortMappings(map[string]string{ //nolint:gosec
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_artist_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
"rated_at": "rating, rated_at",
|
||||
|
||||
@@ -17,45 +17,45 @@ import (
|
||||
var _ = Describe("Collation", func() {
|
||||
conn := db.Db()
|
||||
DescribeTable("Column collation",
|
||||
func(table, column, expectedCollation string) {
|
||||
Expect(checkCollation(conn, table, column, expectedCollation)).To(Succeed())
|
||||
func(table, column string) {
|
||||
Expect(checkCollation(conn, table, column)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name", "NATURALSORT"),
|
||||
Entry("artist.sort_artist_name", "artist", "sort_artist_name", "NATURALSORT"),
|
||||
Entry("album.order_album_name", "album", "order_album_name", "NATURALSORT"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name", "NATURALSORT"),
|
||||
Entry("album.sort_album_name", "album", "sort_album_name", "NATURALSORT"),
|
||||
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name", "NATURALSORT"),
|
||||
Entry("media_file.order_title", "media_file", "order_title", "NATURALSORT"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name", "NATURALSORT"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name", "NATURALSORT"),
|
||||
Entry("media_file.sort_title", "media_file", "sort_title", "NATURALSORT"),
|
||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name", "NATURALSORT"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name", "NATURALSORT"),
|
||||
Entry("playlist.name", "playlist", "name", "NATURALSORT"),
|
||||
Entry("radio.name", "radio", "name", "NATURALSORT"),
|
||||
Entry("user.name", "user", "name", "NOCASE"),
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name"),
|
||||
Entry("artist.sort_artist_name", "artist", "sort_artist_name"),
|
||||
Entry("album.order_album_name", "album", "order_album_name"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name"),
|
||||
Entry("album.sort_album_name", "album", "sort_album_name"),
|
||||
Entry("album.sort_album_artist_name", "album", "sort_album_artist_name"),
|
||||
Entry("media_file.order_title", "media_file", "order_title"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name"),
|
||||
Entry("media_file.sort_title", "media_file", "sort_title"),
|
||||
Entry("media_file.sort_album_name", "media_file", "sort_album_name"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"),
|
||||
Entry("playlist.name", "playlist", "name"),
|
||||
Entry("radio.name", "radio", "name"),
|
||||
Entry("user.name", "user", "name"),
|
||||
)
|
||||
|
||||
DescribeTable("Index collation",
|
||||
func(table, column string) {
|
||||
Expect(checkIndexUsage(conn, table, column)).To(Succeed())
|
||||
},
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name collate NATURALSORT"),
|
||||
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
|
||||
Entry("album.order_album_name", "album", "order_album_name collate NATURALSORT"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate NATURALSORT"),
|
||||
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
|
||||
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NATURALSORT"),
|
||||
Entry("media_file.order_title", "media_file", "order_title collate NATURALSORT"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name collate NATURALSORT"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate NATURALSORT"),
|
||||
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate NATURALSORT"),
|
||||
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate NATURALSORT"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate NATURALSORT"),
|
||||
Entry("artist.order_artist_name", "artist", "order_artist_name collate nocase"),
|
||||
Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("album.order_album_name", "album", "order_album_name collate nocase"),
|
||||
Entry("album.order_album_artist_name", "album", "order_album_artist_name collate nocase"),
|
||||
Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase"),
|
||||
Entry("media_file.order_title", "media_file", "order_title collate nocase"),
|
||||
Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"),
|
||||
Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"),
|
||||
Entry("media_file.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"),
|
||||
Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"),
|
||||
Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"),
|
||||
Entry("media_file.path", "media_file", "path collate nocase"),
|
||||
Entry("playlist.name", "playlist", "name collate NATURALSORT"),
|
||||
Entry("radio.name", "radio", "name collate NATURALSORT"),
|
||||
Entry("playlist.name", "playlist", "name collate nocase"),
|
||||
Entry("radio.name", "radio", "name collate nocase"),
|
||||
Entry("user.user_name", "user", "user_name collate nocase"),
|
||||
)
|
||||
})
|
||||
@@ -91,7 +91,7 @@ order by %[2]s`, table, column))
|
||||
return errors.New("no rows returned")
|
||||
}
|
||||
|
||||
func checkCollation(conn *sql.DB, table, column, expectedCollation string) error {
|
||||
func checkCollation(conn *sql.DB, table string, column string) error {
|
||||
rows, err := conn.Query(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND tbl_name='%s'", table))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -113,12 +113,12 @@ func checkCollation(conn *sql.DB, table, column, expectedCollation string) error
|
||||
if !re.MatchString(res) {
|
||||
return fmt.Errorf("column '%s' not found in table '%s'", column, table)
|
||||
}
|
||||
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+%s`, column, expectedCollation))
|
||||
re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+NOCASE`, column))
|
||||
if re.MatchString(res) {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("table '%s' not found", table)
|
||||
}
|
||||
return fmt.Errorf("column '%s' in table '%s' does not have %s collation", column, table, expectedCollation)
|
||||
return fmt.Errorf("column '%s' in table '%s' does not have NOCASE collation", column, table)
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@ func (e existsCond) ToSql() (string, []any, error) {
|
||||
|
||||
var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`)
|
||||
|
||||
// mapSortOrder converts order_* columns to an expression using sort_* columns with NATURALSORT collation. Example:
|
||||
// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate NATURALSORT)
|
||||
// Convert the order_* columns to an expression using sort_* columns. Example:
|
||||
// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase)
|
||||
// It finds order column names anywhere in the substring
|
||||
func mapSortOrder(tableName, order string) string {
|
||||
order = strings.ToLower(order)
|
||||
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate NATURALSORT)", tableName)
|
||||
repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName)
|
||||
return sortOrderRegex.ReplaceAllString(order, repl)
|
||||
}
|
||||
|
||||
@@ -94,13 +94,13 @@ var _ = Describe("Helpers", func() {
|
||||
sort := "ORDER_ALBUM_NAME asc"
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` +
|
||||
` collate NATURALSORT) asc`))
|
||||
` collate nocase) asc`))
|
||||
})
|
||||
It("changes multiple order columns to sort expressions", func() {
|
||||
sort := "compilation, order_title asc, order_album_artist_name desc, year desc"
|
||||
mapped := mapSortOrder("album", sort)
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate NATURALSORT) asc,` +
|
||||
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate NATURALSORT) desc, year desc`))
|
||||
Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` +
|
||||
` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -148,9 +148,7 @@ func (r *mediaFileRepository) Exists(id string) (bool, error) {
|
||||
}
|
||||
|
||||
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
|
||||
if m.CreatedAt.IsZero() {
|
||||
m.CreatedAt = time.Now()
|
||||
}
|
||||
m.CreatedAt = time.Now()
|
||||
id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -104,68 +104,6 @@ var _ = Describe("MediaRepository", func() {
|
||||
}
|
||||
})
|
||||
|
||||
Describe("Put CreatedAt behavior (#5050)", func() {
|
||||
It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() {
|
||||
before := time.Now().Add(-time.Second)
|
||||
newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "/test/created-at-zero.mp3"}
|
||||
Expect(mr.Put(&newFile)).To(Succeed())
|
||||
|
||||
retrieved, err := mr.Get(newFile.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(retrieved.CreatedAt).To(BeTemporally(">", before))
|
||||
|
||||
_ = mr.Delete(newFile.ID)
|
||||
})
|
||||
|
||||
It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() {
|
||||
originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC)
|
||||
newFile := model.MediaFile{
|
||||
ID: id.NewRandom(),
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-preserved.mp3",
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
Expect(mr.Put(&newFile)).To(Succeed())
|
||||
|
||||
retrieved, err := mr.Get(newFile.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
|
||||
|
||||
_ = mr.Delete(newFile.ID)
|
||||
})
|
||||
|
||||
It("does not reset CreatedAt when updating an existing file", func() {
|
||||
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
fileID := id.NewRandom()
|
||||
newFile := model.MediaFile{
|
||||
ID: fileID,
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-update.mp3",
|
||||
Title: "Original Title",
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
Expect(mr.Put(&newFile)).To(Succeed())
|
||||
|
||||
// Update the file with a new title but zero CreatedAt
|
||||
updatedFile := model.MediaFile{
|
||||
ID: fileID,
|
||||
LibraryID: 1,
|
||||
Path: "/test/created-at-update.mp3",
|
||||
Title: "Updated Title",
|
||||
// CreatedAt is zero - should NOT overwrite the stored value
|
||||
}
|
||||
Expect(mr.Put(&updatedFile)).To(Succeed())
|
||||
|
||||
retrieved, err := mr.Get(fileID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(retrieved.Title).To(Equal("Updated Title"))
|
||||
// CreatedAt should still be the original time (not reset)
|
||||
Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second))
|
||||
|
||||
_ = mr.Delete(fileID)
|
||||
})
|
||||
})
|
||||
|
||||
It("checks existence of mediafiles in the DB", func() {
|
||||
Expect(mr.Exists(songAntenna.ID)).To(BeTrue())
|
||||
Expect(mr.Exists("666")).To(BeFalse())
|
||||
|
||||
@@ -71,8 +71,8 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun
|
||||
//
|
||||
// If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression,
|
||||
// which gives precedence to sort tags.
|
||||
// Ex: order_title => (coalesce(nullif(sort_title,""), order_title) collate NATURALSORT)
|
||||
// To avoid performance issues, indexes should be created for these sort expressions.
|
||||
// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase)
|
||||
// To avoid performance issues, indexes should be created for these sort expressions
|
||||
//
|
||||
// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example,
|
||||
// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly.
|
||||
|
||||
@@ -158,7 +158,7 @@ func writeTargetsToFile(targets []model.ScanTarget) (string, error) {
|
||||
|
||||
for _, target := range targets {
|
||||
if _, err := fmt.Fprintln(tmpFile, target.String()); err != nil {
|
||||
os.Remove(tmpFile.Name()) //nolint:gosec
|
||||
os.Remove(tmpFile.Name())
|
||||
return "", fmt.Errorf("failed to write to temp file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package scanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -268,10 +267,6 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
|
||||
oldAlbumID := missing.AlbumID
|
||||
newAlbumID := target.AlbumID
|
||||
|
||||
// Preserve the original created_at from the missing file, so moved tracks
|
||||
// don't appear in "Recently Added"
|
||||
target.CreatedAt = missing.CreatedAt
|
||||
|
||||
// Update the target media file with the missing file's ID. This effectively "moves" the track
|
||||
// to the new location while keeping its annotations and references intact.
|
||||
target.ID = missing.ID
|
||||
@@ -303,14 +298,6 @@ func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error
|
||||
log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err)
|
||||
}
|
||||
|
||||
// Keep created_at field from previous instance of the album, so moved albums
|
||||
// don't appear in "Recently Added"
|
||||
if err := tx.Album(p.ctx).CopyAttributes(oldAlbumID, newAlbumID, "created_at"); err != nil {
|
||||
if !errors.Is(err, model.ErrNotFound) {
|
||||
log.Warn(p.ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here
|
||||
p.processedAlbumAnnotations[newAlbumID] = true
|
||||
}
|
||||
|
||||
@@ -724,120 +724,6 @@ var _ = Describe("phaseMissingTracks", func() {
|
||||
}) // End of Context "with multiple libraries"
|
||||
})
|
||||
|
||||
Describe("CreatedAt preservation (#5050)", func() {
|
||||
var albumRepo *tests.MockAlbumRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
albumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
albumRepo.ReassignAnnotationCalls = make(map[string]string)
|
||||
albumRepo.CopyAttributesCalls = make(map[string]string)
|
||||
})
|
||||
|
||||
It("should preserve the missing track's created_at when moving within a library", func() {
|
||||
originalTime := time.Date(2020, 3, 15, 10, 0, 0, 0, time.UTC)
|
||||
missingTrack := model.MediaFile{
|
||||
ID: "1", PID: "A", Path: "old/song.mp3",
|
||||
AlbumID: "album-1",
|
||||
LibraryID: 1,
|
||||
CreatedAt: originalTime,
|
||||
Tags: model.Tags{"title": []string{"My Song"}},
|
||||
Size: 100,
|
||||
}
|
||||
matchedTrack := model.MediaFile{
|
||||
ID: "2", PID: "A", Path: "new/song.mp3",
|
||||
AlbumID: "album-1", // Same album
|
||||
LibraryID: 1,
|
||||
CreatedAt: time.Now(), // Much newer
|
||||
Tags: model.Tags{"title": []string{"My Song"}},
|
||||
Size: 100,
|
||||
}
|
||||
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
|
||||
in := &missingTracks{
|
||||
missing: []model.MediaFile{missingTrack},
|
||||
matched: []model.MediaFile{matchedTrack},
|
||||
}
|
||||
|
||||
_, err := phase.processMissingTracks(in)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
movedTrack, _ := ds.MediaFile(ctx).Get("1")
|
||||
Expect(movedTrack.Path).To(Equal("new/song.mp3"))
|
||||
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
|
||||
})
|
||||
|
||||
It("should preserve created_at during cross-library moves with album change", func() {
|
||||
originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
missingTrack := model.MediaFile{
|
||||
ID: "missing-ca", PID: "B", Path: "lib1/song.mp3",
|
||||
AlbumID: "old-album",
|
||||
LibraryID: 1,
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
matchedTrack := model.MediaFile{
|
||||
ID: "matched-ca", PID: "B", Path: "lib2/song.mp3",
|
||||
AlbumID: "new-album",
|
||||
LibraryID: 2,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Set up albums so CopyAttributes can find them
|
||||
albumRepo.SetData(model.Albums{
|
||||
{ID: "old-album", LibraryID: 1, CreatedAt: originalTime},
|
||||
{ID: "new-album", LibraryID: 2, CreatedAt: time.Now()},
|
||||
})
|
||||
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
|
||||
err := phase.moveMatched(matchedTrack, missingTrack)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Track's created_at should be preserved from the missing file
|
||||
movedTrack, _ := ds.MediaFile(ctx).Get("missing-ca")
|
||||
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
|
||||
|
||||
// Album's created_at should be copied from old to new
|
||||
Expect(albumRepo.CopyAttributesCalls).To(HaveKeyWithValue("old-album", "new-album"))
|
||||
|
||||
// Verify the new album's CreatedAt was actually updated
|
||||
newAlbum, err := albumRepo.Get("new-album")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(newAlbum.CreatedAt).To(Equal(originalTime))
|
||||
})
|
||||
|
||||
It("should not copy album created_at when album ID does not change", func() {
|
||||
originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
missingTrack := model.MediaFile{
|
||||
ID: "missing-same", PID: "C", Path: "dir1/song.mp3",
|
||||
AlbumID: "same-album",
|
||||
LibraryID: 1,
|
||||
CreatedAt: originalTime,
|
||||
}
|
||||
matchedTrack := model.MediaFile{
|
||||
ID: "matched-same", PID: "C", Path: "dir2/song.mp3",
|
||||
AlbumID: "same-album", // Same album
|
||||
LibraryID: 1,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
_ = ds.MediaFile(ctx).Put(&missingTrack)
|
||||
_ = ds.MediaFile(ctx).Put(&matchedTrack)
|
||||
|
||||
err := phase.moveMatched(matchedTrack, missingTrack)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Track's created_at should still be preserved
|
||||
movedTrack, _ := ds.MediaFile(ctx).Get("missing-same")
|
||||
Expect(movedTrack.CreatedAt).To(Equal(originalTime))
|
||||
|
||||
// CopyAttributes should NOT have been called (same album)
|
||||
Expect(albumRepo.CopyAttributesCalls).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Album Annotation Reassignment", func() {
|
||||
var (
|
||||
albumRepo *tests.MockAlbumRepo
|
||||
|
||||
@@ -80,7 +80,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e
|
||||
}
|
||||
c := http.Client{Timeout: imageRequestTimeout}
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil)
|
||||
resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper
|
||||
resp, err := c.Do(req) //nolint:bodyclose // No need to close resp.Body, it will be closed via the CachedStream wrapper
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline)
|
||||
return strings.NewReader(string(defaultImage)), nil
|
||||
|
||||
@@ -104,7 +104,7 @@ func writeEvent(ctx context.Context, w io.Writer, event message, timeout time.Du
|
||||
log.Debug(ctx, "Error setting write timeout", err)
|
||||
}
|
||||
|
||||
_, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data) //nolint:gosec
|
||||
_, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
184
server/nativeapi/apimodel/song.go
Normal file
184
server/nativeapi/apimodel/song.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package apimodel
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// Song is the API representation of a media file. It decouples the API response
|
||||
// from the internal model.MediaFile, allowing us to expose calculated fields,
|
||||
// hide internal details, and evolve the API independently of the database schema.
|
||||
type Song struct {
|
||||
ID string `json:"id"`
|
||||
LibraryID int `json:"libraryId"`
|
||||
LibraryName string `json:"libraryName"`
|
||||
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album"`
|
||||
AlbumID string `json:"albumId"`
|
||||
Artist string `json:"artist"`
|
||||
ArtistID string `json:"artistId"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumArtistID string `json:"albumArtistId"`
|
||||
Compilation bool `json:"compilation"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
DiscSubtitle string `json:"discSubtitle,omitempty"`
|
||||
|
||||
// Dates
|
||||
Year int `json:"year"`
|
||||
Date string `json:"date,omitempty"`
|
||||
OriginalYear int `json:"originalYear"`
|
||||
OriginalDate string `json:"originalDate,omitempty"`
|
||||
ReleaseYear int `json:"releaseYear"`
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
|
||||
// Audio properties
|
||||
Duration float32 `json:"duration"`
|
||||
Size int64 `json:"size"`
|
||||
Suffix string `json:"suffix"`
|
||||
BitRate int `json:"bitRate"`
|
||||
SampleRate int `json:"sampleRate"`
|
||||
BitDepth int `json:"bitDepth"`
|
||||
Channels int `json:"channels"`
|
||||
|
||||
// Metadata
|
||||
Genre string `json:"genre"`
|
||||
Genres model.Genres `json:"genres,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
BPM int `json:"bpm,omitempty"`
|
||||
ExplicitStatus string `json:"explicitStatus"`
|
||||
CatalogNum string `json:"catalogNum,omitempty"`
|
||||
Tags model.Tags `json:"tags,omitempty"`
|
||||
Participants model.Participants `json:"participants"`
|
||||
|
||||
// Sort fields
|
||||
SortTitle string `json:"sortTitle,omitempty"`
|
||||
SortAlbumName string `json:"sortAlbumName,omitempty"`
|
||||
SortArtistName string `json:"sortArtistName,omitempty"`
|
||||
SortAlbumArtistName string `json:"sortAlbumArtistName,omitempty"`
|
||||
|
||||
// MusicBrainz IDs
|
||||
MbzRecordingID string `json:"mbzRecordingID,omitempty"`
|
||||
MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
MbzAlbumID string `json:"mbzAlbumId,omitempty"`
|
||||
MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
MbzArtistID string `json:"mbzArtistId,omitempty"`
|
||||
MbzAlbumArtistID string `json:"mbzAlbumArtistId,omitempty"`
|
||||
MbzAlbumType string `json:"mbzAlbumType,omitempty"`
|
||||
MbzAlbumComment string `json:"mbzAlbumComment,omitempty"`
|
||||
|
||||
// ReplayGain
|
||||
RGAlbumGain *float64 `json:"rgAlbumGain"`
|
||||
RGAlbumPeak *float64 `json:"rgAlbumPeak"`
|
||||
RGTrackGain *float64 `json:"rgTrackGain"`
|
||||
RGTrackPeak *float64 `json:"rgTrackPeak"`
|
||||
|
||||
// Lyrics
|
||||
Lyrics string `json:"lyrics"`
|
||||
|
||||
HasCoverArt bool `json:"hasCoverArt"`
|
||||
|
||||
// User annotations
|
||||
PlayCount int64 `json:"playCount,omitempty"`
|
||||
PlayDate *time.Time `json:"playDate,omitempty"`
|
||||
Rating int `json:"rating,omitempty"`
|
||||
Starred bool `json:"starred,omitempty"`
|
||||
StarredAt *time.Time `json:"starredAt,omitempty"`
|
||||
|
||||
// Bookmark
|
||||
BookmarkPosition int64 `json:"bookmarkPosition"`
|
||||
|
||||
// Timestamps
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// FromMediaFile converts a model.MediaFile into the API Song representation.
|
||||
func FromMediaFile(mf model.MediaFile) Song {
|
||||
return Song{
|
||||
ID: mf.ID,
|
||||
LibraryID: mf.LibraryID,
|
||||
LibraryName: mf.LibraryName,
|
||||
|
||||
Title: mf.Title,
|
||||
Album: mf.Album,
|
||||
AlbumID: mf.AlbumID,
|
||||
Artist: mf.Artist,
|
||||
ArtistID: mf.ArtistID,
|
||||
AlbumArtist: mf.AlbumArtist,
|
||||
AlbumArtistID: mf.AlbumArtistID,
|
||||
Compilation: mf.Compilation,
|
||||
TrackNumber: mf.TrackNumber,
|
||||
DiscNumber: mf.DiscNumber,
|
||||
DiscSubtitle: mf.DiscSubtitle,
|
||||
|
||||
Year: mf.Year,
|
||||
Date: mf.Date,
|
||||
OriginalYear: mf.OriginalYear,
|
||||
OriginalDate: mf.OriginalDate,
|
||||
ReleaseYear: mf.ReleaseYear,
|
||||
ReleaseDate: mf.ReleaseDate,
|
||||
|
||||
Duration: mf.Duration,
|
||||
Size: mf.Size,
|
||||
Suffix: mf.Suffix,
|
||||
BitRate: mf.BitRate,
|
||||
SampleRate: mf.SampleRate,
|
||||
BitDepth: mf.BitDepth,
|
||||
Channels: mf.Channels,
|
||||
|
||||
Genre: mf.Genre,
|
||||
Genres: mf.Genres,
|
||||
Comment: mf.Comment,
|
||||
BPM: mf.BPM,
|
||||
ExplicitStatus: mf.ExplicitStatus,
|
||||
CatalogNum: mf.CatalogNum,
|
||||
Tags: mf.Tags,
|
||||
Participants: mf.Participants,
|
||||
|
||||
SortTitle: mf.SortTitle,
|
||||
SortAlbumName: mf.SortAlbumName,
|
||||
SortArtistName: mf.SortArtistName,
|
||||
SortAlbumArtistName: mf.SortAlbumArtistName,
|
||||
|
||||
MbzRecordingID: mf.MbzRecordingID,
|
||||
MbzReleaseTrackID: mf.MbzReleaseTrackID,
|
||||
MbzAlbumID: mf.MbzAlbumID,
|
||||
MbzReleaseGroupID: mf.MbzReleaseGroupID,
|
||||
MbzArtistID: mf.MbzArtistID,
|
||||
MbzAlbumArtistID: mf.MbzAlbumArtistID,
|
||||
MbzAlbumType: mf.MbzAlbumType,
|
||||
MbzAlbumComment: mf.MbzAlbumComment,
|
||||
|
||||
RGAlbumGain: mf.RGAlbumGain,
|
||||
RGAlbumPeak: mf.RGAlbumPeak,
|
||||
RGTrackGain: mf.RGTrackGain,
|
||||
RGTrackPeak: mf.RGTrackPeak,
|
||||
|
||||
Lyrics: mf.Lyrics,
|
||||
|
||||
HasCoverArt: mf.HasCoverArt,
|
||||
|
||||
PlayCount: mf.PlayCount,
|
||||
PlayDate: mf.PlayDate,
|
||||
Rating: mf.Rating,
|
||||
Starred: mf.Starred,
|
||||
StarredAt: mf.StarredAt,
|
||||
|
||||
BookmarkPosition: mf.BookmarkPosition,
|
||||
|
||||
CreatedAt: mf.CreatedAt,
|
||||
UpdatedAt: mf.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// FromMediaFiles converts a slice of model.MediaFile into a slice of API Songs.
|
||||
func FromMediaFiles(mfs model.MediaFiles) []Song {
|
||||
songs := make([]Song, len(mfs))
|
||||
for i, mf := range mfs {
|
||||
songs[i] = FromMediaFile(mf)
|
||||
}
|
||||
return songs
|
||||
}
|
||||
132
server/nativeapi/apimodel/song_test.go
Normal file
132
server/nativeapi/apimodel/song_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package apimodel_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/nativeapi/apimodel"
|
||||
)
|
||||
|
||||
func TestFromMediaFile(t *testing.T) {
|
||||
now := time.Now()
|
||||
playDate := now.Add(-time.Hour)
|
||||
starredAt := now.Add(-2 * time.Hour)
|
||||
rgGain := 1.5
|
||||
rgPeak := 0.9
|
||||
|
||||
mf := model.MediaFile{
|
||||
ID: "mf-1",
|
||||
LibraryID: 1,
|
||||
LibraryName: "My Music",
|
||||
LibraryPath: "/music",
|
||||
FolderID: "folder-1",
|
||||
Path: "/music/song.mp3",
|
||||
Title: "Test Song",
|
||||
Album: "Test Album",
|
||||
AlbumID: "album-1",
|
||||
Artist: "Test Artist",
|
||||
ArtistID: "artist-1",
|
||||
AlbumArtist: "Test Album Artist",
|
||||
Duration: 180.5,
|
||||
Size: 5242880,
|
||||
Suffix: "mp3",
|
||||
BitRate: 320,
|
||||
SampleRate: 44100,
|
||||
BitDepth: 16,
|
||||
Channels: 2,
|
||||
Year: 2024,
|
||||
TrackNumber: 3,
|
||||
DiscNumber: 1,
|
||||
Genre: "Rock",
|
||||
HasCoverArt: true,
|
||||
RGAlbumGain: &rgGain,
|
||||
RGAlbumPeak: &rgPeak,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
mf.PlayCount = 5
|
||||
mf.PlayDate = &playDate
|
||||
mf.Starred = true
|
||||
mf.StarredAt = &starredAt
|
||||
mf.Rating = 4
|
||||
mf.BookmarkPosition = 1000
|
||||
|
||||
song := apimodel.FromMediaFile(mf)
|
||||
|
||||
// Verify mapped fields
|
||||
if song.ID != mf.ID {
|
||||
t.Errorf("ID: got %q, want %q", song.ID, mf.ID)
|
||||
}
|
||||
if song.Title != mf.Title {
|
||||
t.Errorf("Title: got %q, want %q", song.Title, mf.Title)
|
||||
}
|
||||
if song.Artist != mf.Artist {
|
||||
t.Errorf("Artist: got %q, want %q", song.Artist, mf.Artist)
|
||||
}
|
||||
if song.Album != mf.Album {
|
||||
t.Errorf("Album: got %q, want %q", song.Album, mf.Album)
|
||||
}
|
||||
if song.Duration != mf.Duration {
|
||||
t.Errorf("Duration: got %v, want %v", song.Duration, mf.Duration)
|
||||
}
|
||||
if song.BitRate != mf.BitRate {
|
||||
t.Errorf("BitRate: got %d, want %d", song.BitRate, mf.BitRate)
|
||||
}
|
||||
if song.LibraryID != mf.LibraryID {
|
||||
t.Errorf("LibraryID: got %d, want %d", song.LibraryID, mf.LibraryID)
|
||||
}
|
||||
if song.LibraryName != mf.LibraryName {
|
||||
t.Errorf("LibraryName: got %q, want %q", song.LibraryName, mf.LibraryName)
|
||||
}
|
||||
|
||||
// Verify annotations are mapped
|
||||
if song.PlayCount != mf.PlayCount {
|
||||
t.Errorf("PlayCount: got %d, want %d", song.PlayCount, mf.PlayCount)
|
||||
}
|
||||
if song.Starred != mf.Starred {
|
||||
t.Errorf("Starred: got %v, want %v", song.Starred, mf.Starred)
|
||||
}
|
||||
if song.Rating != mf.Rating {
|
||||
t.Errorf("Rating: got %d, want %d", song.Rating, mf.Rating)
|
||||
}
|
||||
|
||||
// Verify bookmark is mapped
|
||||
if song.BookmarkPosition != mf.BookmarkPosition {
|
||||
t.Errorf("BookmarkPosition: got %d, want %d", song.BookmarkPosition, mf.BookmarkPosition)
|
||||
}
|
||||
|
||||
// Verify replay gain pointers are mapped
|
||||
if song.RGAlbumGain == nil || *song.RGAlbumGain != rgGain {
|
||||
t.Errorf("RGAlbumGain: got %v, want %v", song.RGAlbumGain, &rgGain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMediaFiles(t *testing.T) {
|
||||
mfs := model.MediaFiles{
|
||||
{ID: "1", Title: "Song 1"},
|
||||
{ID: "2", Title: "Song 2"},
|
||||
{ID: "3", Title: "Song 3"},
|
||||
}
|
||||
|
||||
songs := apimodel.FromMediaFiles(mfs)
|
||||
|
||||
if len(songs) != 3 {
|
||||
t.Fatalf("expected 3 songs, got %d", len(songs))
|
||||
}
|
||||
for i, song := range songs {
|
||||
if song.ID != mfs[i].ID {
|
||||
t.Errorf("song[%d].ID: got %q, want %q", i, song.ID, mfs[i].ID)
|
||||
}
|
||||
if song.Title != mfs[i].Title {
|
||||
t.Errorf("song[%d].Title: got %q, want %q", i, song.Title, mfs[i].Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMediaFilesEmpty(t *testing.T) {
|
||||
songs := apimodel.FromMediaFiles(model.MediaFiles{})
|
||||
if len(songs) != 0 {
|
||||
t.Fatalf("expected 0 songs, got %d", len(songs))
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func inspect(ds model.DataStore) http.HandlerFunc {
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if _, err := w.Write(response); err != nil { //nolint:gosec
|
||||
if _, err := w.Write(response); err != nil {
|
||||
log.Error(ctx, "Error sending response to client", err)
|
||||
}
|
||||
}
|
||||
|
||||
49
server/nativeapi/mapped_repository.go
Normal file
49
server/nativeapi/mapped_repository.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
// mappedRepository wraps a rest.Repository and transforms its Read/ReadAll results
|
||||
// using a mapping function. This allows the native API to return different types than
|
||||
// what the persistence layer provides, enabling calculated fields and decoupling the
|
||||
// API response shape from the database model.
|
||||
//
|
||||
// The mapFunc receives the raw result from the underlying repository and returns the
|
||||
// transformed result for the API response. It handles both single items (from Read)
|
||||
// and collections (from ReadAll).
|
||||
type mappedRepository struct {
|
||||
repo rest.Repository
|
||||
mapOne func(any) (any, error)
|
||||
mapMany func(any) (any, error)
|
||||
}
|
||||
|
||||
func (m *mappedRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return m.repo.Count(options...)
|
||||
}
|
||||
|
||||
func (m *mappedRepository) Read(id string) (any, error) {
|
||||
result, err := m.repo.Read(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.mapOne(result)
|
||||
}
|
||||
|
||||
func (m *mappedRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
result, err := m.repo.ReadAll(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.mapMany(result)
|
||||
}
|
||||
|
||||
func (m *mappedRepository) EntityName() string {
|
||||
return m.repo.EntityName()
|
||||
}
|
||||
|
||||
func (m *mappedRepository) NewInstance() any {
|
||||
return m.repo.NewInstance()
|
||||
}
|
||||
|
||||
var _ rest.Repository = (*mappedRepository)(nil)
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/nativeapi/apimodel"
|
||||
)
|
||||
|
||||
// PluginManager defines the interface for plugin management operations.
|
||||
@@ -63,7 +64,7 @@ func (api *Router) routes() http.Handler {
|
||||
r.Use(server.JWTRefresher)
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
api.RX(r, "/user", api.users.NewRepository, true)
|
||||
api.R(r, "/song", model.MediaFile{}, false)
|
||||
api.addSongRoute(r)
|
||||
api.R(r, "/album", model.Album{}, false)
|
||||
api.R(r, "/artist", model.Artist{}, false)
|
||||
api.R(r, "/genre", model.Genre{}, false)
|
||||
@@ -102,6 +103,24 @@ func (api *Router) R(r chi.Router, pathPrefix string, model any, persistable boo
|
||||
api.RX(r, pathPrefix, constructor, persistable)
|
||||
}
|
||||
|
||||
func (api *Router) addSongRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
repo := api.ds.Resource(ctx, model.MediaFile{})
|
||||
return &mappedRepository{
|
||||
repo: repo,
|
||||
mapOne: func(v any) (any, error) {
|
||||
mf := v.(*model.MediaFile)
|
||||
return apimodel.FromMediaFile(*mf), nil
|
||||
},
|
||||
mapMany: func(v any) (any, error) {
|
||||
mfs := v.(model.MediaFiles)
|
||||
return apimodel.FromMediaFiles(mfs), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
api.RX(r, "/song", constructor, false)
|
||||
}
|
||||
|
||||
func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||
r.Route(pathPrefix, func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
@@ -207,7 +226,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
_, err = w.Write(resp) //nolint:gosec
|
||||
_, err = w.Write(resp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
@@ -243,7 +262,7 @@ func (api *Router) addInsightsRoute(r chi.Router) {
|
||||
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
last, success := api.insights.LastRun(r.Context())
|
||||
if conf.Server.EnableInsightsCollector {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) //nolint:gosec
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/nativeapi/apimodel"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -127,13 +128,13 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Describe("GET /song", func() {
|
||||
Context("when user is authenticated", func() {
|
||||
It("returns all songs", func() {
|
||||
It("returns all songs as apimodel.Song types", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
var response []apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@@ -144,6 +145,22 @@ var _ = Describe("Song Endpoints", func() {
|
||||
Expect(response[1].Title).To(Equal("Test Song 2"))
|
||||
})
|
||||
|
||||
It("does not expose internal model fields like Path", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// Parse into a raw map to check that Path is not present
|
||||
var rawResponse []map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &rawResponse)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rawResponse).To(HaveLen(2))
|
||||
Expect(rawResponse[0]).ToNot(HaveKey("path"))
|
||||
Expect(rawResponse[0]).ToNot(HaveKey("folderId"))
|
||||
Expect(rawResponse[0]).ToNot(HaveKey("libraryPath"))
|
||||
})
|
||||
|
||||
It("handles repository errors gracefully", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
@@ -166,13 +183,13 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Describe("GET /song/{id}", func() {
|
||||
Context("when user is authenticated", func() {
|
||||
It("returns the specific song", func() {
|
||||
It("returns the specific song as apimodel.Song type", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response model.MediaFile
|
||||
var response apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@@ -265,7 +282,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
var response []apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@@ -280,7 +297,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
var response []apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@@ -299,7 +316,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
var response []apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@@ -349,7 +366,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
var response []apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
|
||||
@@ -19,33 +19,47 @@ import (
|
||||
|
||||
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
||||
|
||||
func playlistTracksHandler(ds model.DataStore, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
tracks := ds.Playlist(r.Context()).Tracks(plsId, refreshSmartPlaylist(r))
|
||||
if tracks == nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
handler(func(ctx context.Context) rest.Repository { return tracks }).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
handler := playlistTracksHandler(ds, rest.GetAll, func(r *http.Request) bool {
|
||||
return req.Params(r).Int64Or("_start", 0) == 0
|
||||
})
|
||||
// Add a middleware to capture the playlistId
|
||||
wrapper := func(handler restHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
p := req.Params(r)
|
||||
start := p.Int64Or("_start", 0)
|
||||
return plsRepo.Tracks(plsId, start == 0)
|
||||
}
|
||||
|
||||
handler(constructor).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.ToLower(r.Header.Get("accept")) == "audio/x-mpegurl" {
|
||||
accept := r.Header.Get("accept")
|
||||
if strings.ToLower(accept) == "audio/x-mpegurl" {
|
||||
handleExportPlaylist(ds)(w, r)
|
||||
return
|
||||
}
|
||||
handler(w, r)
|
||||
wrapper(rest.GetAll)(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
|
||||
return playlistTracksHandler(ds, rest.Get, func(*http.Request) bool { return true })
|
||||
// Add a middleware to capture the playlistId
|
||||
wrapper := func(handler restHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
plsRepo := ds.Playlist(ctx)
|
||||
plsId := chi.URLParam(r, "playlistId")
|
||||
return plsRepo.Tracks(plsId, true)
|
||||
}
|
||||
|
||||
handler(constructor).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
return wrapper(rest.Get)
|
||||
}
|
||||
|
||||
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
|
||||
@@ -59,7 +73,7 @@ func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
|
||||
_, err = w.Write([]byte(pls.ToM3U8()))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending m3u contents", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -90,7 +104,7 @@ func handleExportPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name)
|
||||
w.Header().Set("Content-Disposition", disposition)
|
||||
|
||||
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
|
||||
_, err = w.Write([]byte(pls.ToM3U8()))
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error sending playlist", "name", pls.Name)
|
||||
return
|
||||
@@ -162,7 +176,7 @@ func addToPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||
count += c
|
||||
|
||||
// Must return an object with an ID, to satisfy ReactAdmin `create` call
|
||||
_, err = fmt.Fprintf(w, `{"added":%d}`, count) //nolint:gosec
|
||||
_, err = fmt.Fprintf(w, `{"added":%d}`, count)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
@@ -204,7 +218,7 @@ func reorderItem(ds model.DataStore) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id)) //nolint:gosec
|
||||
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
@@ -225,6 +239,6 @@ func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(data) //nolint:gosec
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
type mockPlaylistTrackRepo struct {
|
||||
model.PlaylistTrackRepository
|
||||
tracks model.PlaylistTracks
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) Count(...rest.QueryOptions) (int64, error) {
|
||||
return int64(len(m.tracks)), nil
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) ReadAll(...rest.QueryOptions) (any, error) {
|
||||
return m.tracks, nil
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) EntityName() string {
|
||||
return "playlist_track"
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) NewInstance() any {
|
||||
return &model.PlaylistTrack{}
|
||||
}
|
||||
|
||||
func (m *mockPlaylistTrackRepo) Read(id string) (any, error) {
|
||||
for _, t := range m.tracks {
|
||||
if t.ID == id {
|
||||
return &t, nil
|
||||
}
|
||||
}
|
||||
return nil, rest.ErrNotFound
|
||||
}
|
||||
|
||||
var _ = Describe("Playlist Tracks Endpoint", func() {
|
||||
var (
|
||||
router http.Handler
|
||||
ds *tests.MockDataStore
|
||||
plsRepo *tests.MockPlaylistRepo
|
||||
userRepo *tests.MockedUserRepo
|
||||
w *httptest.ResponseRecorder
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.SessionTimeout = time.Minute
|
||||
|
||||
plsRepo = &tests.MockPlaylistRepo{}
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPlaylist: plsRepo,
|
||||
MockedUser: userRepo,
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
}
|
||||
|
||||
auth.Init(ds)
|
||||
|
||||
testUser := model.User{
|
||||
ID: "user-1",
|
||||
UserName: "testuser",
|
||||
Name: "Test User",
|
||||
IsAdmin: false,
|
||||
NewPassword: "testpass",
|
||||
}
|
||||
err := userRepo.Put(&testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil)
|
||||
router = server.JWTVerifier(nativeRouter)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
createAuthenticatedRequest := func(method, path string) *http.Request {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
testUser := model.User{ID: "user-1", UserName: "testuser"}
|
||||
token, err := auth.CreateToken(&testUser)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token)
|
||||
return req
|
||||
}
|
||||
|
||||
Describe("GET /playlist/{playlistId}/tracks", func() {
|
||||
It("returns 404 when playlist does not exist", func() {
|
||||
req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns tracks when playlist exists", func() {
|
||||
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
|
||||
tracks: model.PlaylistTracks{
|
||||
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
|
||||
{ID: "2", MediaFileID: "mf-2", PlaylistID: "pls-1"},
|
||||
},
|
||||
}
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.PlaylistTrack
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response).To(HaveLen(2))
|
||||
Expect(response[0].ID).To(Equal("1"))
|
||||
Expect(response[1].ID).To(Equal("2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET /playlist/{playlistId}/tracks/{id}", func() {
|
||||
It("returns 404 when playlist does not exist", func() {
|
||||
req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks/1")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
|
||||
It("returns the track when playlist exists", func() {
|
||||
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
|
||||
tracks: model.PlaylistTracks{
|
||||
{ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"},
|
||||
},
|
||||
}
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/1")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response model.PlaylistTrack
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(response.ID).To(Equal("1"))
|
||||
Expect(response.MediaFileID).To(Equal("mf-1"))
|
||||
})
|
||||
|
||||
It("returns 404 when track does not exist in playlist", func() {
|
||||
plsRepo.TracksReturn = &mockPlaylistTrackRepo{
|
||||
tracks: model.PlaylistTracks{},
|
||||
}
|
||||
|
||||
req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/999")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -87,7 +87,7 @@ func getQueue(ds model.DataStore) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(resp) //nolint:gosec
|
||||
_, _ = w.Write(resp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) {
|
||||
s = pub.mapShareToM3U(r, *s)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", "audio/x-mpegurl")
|
||||
_, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec
|
||||
_, _ = w.Write([]byte(s.ToM3U8()))
|
||||
}
|
||||
|
||||
func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) {
|
||||
|
||||
@@ -244,7 +244,7 @@ func (s *Server) frontendAssetsHandler() http.Handler {
|
||||
// It provides detailed error messages for common issues like encrypted private keys.
|
||||
func validateTLSCertificates(certFile, keyFile string) error {
|
||||
// Read the key file to check for encryption
|
||||
keyData, err := os.ReadFile(keyFile) //nolint:gosec
|
||||
keyData, err := os.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading TLS key file: %w", err)
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := w.Write(response); err != nil { //nolint:gosec
|
||||
if _, err := w.Write(response); err != nil {
|
||||
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ type MockAlbumRepo struct {
|
||||
Err bool
|
||||
Options model.QueryOptions
|
||||
ReassignAnnotationCalls map[string]string // prevID -> newID
|
||||
CopyAttributesCalls map[string]string // fromID -> toID
|
||||
}
|
||||
|
||||
func (m *MockAlbumRepo) SetError(err bool) {
|
||||
@@ -143,32 +142,6 @@ func (m *MockAlbumRepo) ReassignAnnotation(prevID string, newID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyAttributes copies attributes from one album to another
|
||||
func (m *MockAlbumRepo) CopyAttributes(fromID, toID string, columns ...string) error {
|
||||
if m.Err {
|
||||
return errors.New("unexpected error")
|
||||
}
|
||||
from, ok := m.Data[fromID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
to, ok := m.Data[toID]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
for _, col := range columns {
|
||||
switch col {
|
||||
case "created_at":
|
||||
to.CreatedAt = from.CreatedAt
|
||||
}
|
||||
}
|
||||
if m.CopyAttributesCalls == nil {
|
||||
m.CopyAttributesCalls = make(map[string]string)
|
||||
}
|
||||
m.CopyAttributesCalls[fromID] = toID
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRating sets the rating for an album
|
||||
func (m *MockAlbumRepo) SetRating(rating int, itemID string) error {
|
||||
if m.Err {
|
||||
|
||||
@@ -8,9 +8,8 @@ import (
|
||||
type MockPlaylistRepo struct {
|
||||
model.PlaylistRepository
|
||||
|
||||
Entity *model.Playlist
|
||||
Error error
|
||||
TracksReturn model.PlaylistTrackRepository
|
||||
Entity *model.Playlist
|
||||
Error error
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) {
|
||||
@@ -23,10 +22,6 @@ func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) {
|
||||
return m.Entity, nil
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository {
|
||||
return m.TracksReturn
|
||||
}
|
||||
|
||||
func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) {
|
||||
if m.Error != nil {
|
||||
return 0, m.Error
|
||||
|
||||
@@ -127,12 +127,10 @@ const reducePlayNext = (state, { data }) => {
|
||||
const newQueue = []
|
||||
const current = state.current || {}
|
||||
let foundPos = false
|
||||
let currentIndex = 0
|
||||
state.queue.forEach((item) => {
|
||||
newQueue.push(item)
|
||||
if (item.uuid === current.uuid) {
|
||||
foundPos = true
|
||||
currentIndex = newQueue.length - 1
|
||||
Object.keys(data).forEach((id) => {
|
||||
newQueue.push(mapToAudioLists(data[id]))
|
||||
})
|
||||
@@ -147,7 +145,6 @@ const reducePlayNext = (state, { data }) => {
|
||||
return {
|
||||
...state,
|
||||
queue: newQueue,
|
||||
playIndex: foundPos ? currentIndex : undefined,
|
||||
clear: true,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user