Compare commits

...

12 Commits

Author SHA1 Message Date
Deluan
a91d7e9453 chore(deps): update go-taglib to v0.0.0-20260220032326 for MKA fixes
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-19 22:32:06 -05:00
Deluan
e033189b6f chore(go-taglib): update version to 2.2 WASM and add debug logging
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-19 21:21:57 -05:00
Deluan
6025746070 chore(make): rename run-docker target to docker-run for consistency
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-19 20:24:15 -05:00
Deluan
d946ecd981 chore(deps): update cross-taglib version to 2.2.0-1
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-19 20:23:58 -05:00
Deluan
8d9143e80e chore(deps): update go-taglib fork with MKA/Matroska support
Bump deluan/go-taglib to cf75207bfff8, which upgrades the underlying
taglib to v2.2 and adds Matroska container format detection and
metadata handling (MKA audio files).
2026-02-19 18:56:42 -05:00
Deluan Quintão
08a71320ea fix(ui): make toggle switches visible in Gruvbox Dark theme (#5063) (#5064)
The secondary color (#3c3836) matches the panel/table cell background,
making checked MuiSwitch thumbs invisible. Add MuiSwitch override using
Gruvbox cyan (#458588), consistent with existing interactive elements.
2026-02-18 15:38:20 -05:00
Raphael Catolino
44a5482493 fix(ui): activity Indicator switching constantly between online/offline (#5054)
When using HTTP2, setting the writeTimeout too low causes the channel to
close before the keepAlive event has a chance of beeing sent.

Signed-off-by: rca <raphael.catolino@gmail.com>
Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-02-17 14:47:20 -05:00
Deluan
5fa8356b31 chore(deps): bump golangci-lint to v2.10.0 and suppress new gosec false positives
Bump golangci-lint from v2.9.0 to v2.10.0, which includes a newer gosec
with additional taint-analysis rules (G117, G703, G704, G705) and a
stricter G101 check. Added inline //nolint:gosec comments to suppress
21 false positives across 19 files: struct fields flagged as secrets
(G117), w.Write calls flagged as XSS (G705), HTTP client calls flagged
as SSRF (G704), os.Stat/os.ReadFile/os.Remove flagged as path traversal
(G703), and a sort mapping flagged as hardcoded credentials (G101).

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 09:28:42 -05:00
Deluan Quintão
cad9cdc53e fix(scanner): preserve created_at when moving songs between libraries (#5055)
* fix: preserve created_at when moving songs between libraries (#5050)

When songs are moved between libraries, their creation date was being
reset to the current time, causing them to incorrectly appear in
"Recently Added". Three changes fix this:

1. Add hash:"ignore" to AlbumID in MediaFile struct so that Equals()
   works for cross-library moves (AlbumID includes library prefix,
   making hashes always differ between libraries)

2. Preserve album created_at in moveMatched() via CopyAttributes,
   matching the pattern already used in persistAlbum() for
   within-library album ID changes

3. Only set CreatedAt in Put() when it's zero (new files), and
   explicitly copy missing.CreatedAt to the target in moveMatched()
   as defense-in-depth for the INSERT code path

* test: add regression tests for created_at preservation (#5050)

Add tests covering the three aspects of the fix:
- Scanner: moveMatched preserves missing track's created_at
- Scanner: CopyAttributes called for album created_at on album change
- Scanner: CopyAttributes not called when album ID stays the same
- Persistence: Put sets CreatedAt to now for new files with zero value
- Persistence: Put preserves non-zero CreatedAt on insert
- Persistence: Put does not reset CreatedAt on update

Also adds CopyAttributes to MockAlbumRepo for test support.

* test: verify album created_at is updated in cross-library move test (#5050)

Added end-to-end assertion in the cross-library move test to verify that
the new album's CreatedAt field is actually set to the original value after
CopyAttributes runs, not just that the method was called. This strengthens
the test by confirming the mock correctly propagates the timestamp.
2026-02-17 08:37:05 -05:00
Deluan
b774133cd1 chore(deps): update go-sqlite3 to v1.14.34 and pocketbase/dbx to v1.12.0
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-17 08:35:02 -05:00
Alanna
a20d56c137 fix(ui): prevent "Play Next" restarting play at top of queue (#5049)
Set playIndex when rebuilding the queue in reducePlayNext so the music
player library knows which track is currently playing. Without this, the
library's loadNewAudioLists defaults playIndex to 0, causing playback to
restart from the top of the queue on rapid "Play Next" actions.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 08:34:24 -05:00
Deluan
b64d8ad334 fix(server): return 404 instead of 500 for non-existent playlists
The native API endpoints GET /playlist/{id}/tracks and
GET /playlist/{id}/tracks/{id} were panicking with a nil pointer
dereference (resulting in a 500) when the playlist did not exist.
This happened because Tracks() returns nil for missing playlists,
and the nil repository was passed directly to the rest handler.
Extracted a shared playlistTracksHandler that checks for nil and
returns 404 early. Added tests covering both the error and happy paths.
2026-02-15 22:39:27 -05:00
37 changed files with 480 additions and 86 deletions

View File

@@ -14,7 +14,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends ffmpeg
# Install TagLib from cross-taglib releases
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
ARG CROSS_TAGLIB_VERSION="2.2.0-1"
ARG TARGETARCH
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \

View File

@@ -8,7 +8,7 @@
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24",
"CROSS_TAGLIB_VERSION": "2.1.1-1"
"CROSS_TAGLIB_VERSION": "2.2.0-1"
}
},
"workspaceMount": "",

View File

@@ -14,7 +14,7 @@ concurrency:
cancel-in-progress: true
env:
CROSS_TAGLIB_VERSION: "2.1.1-2"
CROSS_TAGLIB_VERSION: "2.2.0-1"
CGO_CFLAGS_ALLOW: "--define-prefix"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}

View File

@@ -28,7 +28,7 @@ COPY --from=xx-build /out/ /usr/bin/
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.1.1-2
ARG CROSS_TAGLIB_VERSION=2.2.0-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
# wget in busybox can't follow redirects

View File

@@ -19,8 +19,8 @@ PLATFORMS ?= $(SUPPORTED_PLATFORMS)
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.9.0
CROSS_TAGLIB_VERSION ?= 2.2.0-1
GOLANGCI_LINT_VERSION ?= v2.10.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@@ -201,8 +201,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
@du -h binaries/msi/*.msi
.PHONY: docker-msi
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
if [ -f navidrome.toml ]; then \
@@ -213,7 +213,7 @@ run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker
fi; \
fi; \
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
.PHONY: run-docker
.PHONY: docker-run
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi

View File

@@ -65,7 +65,7 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
}
type authResponse struct {
JWT string `json:"jwt"`
JWT string `json:"jwt"` //nolint:gosec
}
var result authResponse

View File

@@ -20,6 +20,7 @@ import (
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
@@ -43,7 +44,7 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
}
func (e extractor) Version() string {
return "go-taglib (TagLib 2.1.1 WASM)"
return "2.2 WASM"
}
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
@@ -279,4 +280,7 @@ func init() {
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
return &extractor{fsys}
})
conf.AddHook(func() {
log.Debug("go-taglib version", "version", extractor{}.Version())
})
}

View File

@@ -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)))
_, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec
return
}

View File

@@ -57,7 +57,7 @@ type listenBrainzResponse struct {
}
type listenBrainzRequest struct {
ApiKey string
ApiKey string //nolint:gosec
Body listenBrainzRequestBody
}

View File

@@ -172,8 +172,8 @@ type TagConf struct {
type lastfmOptions struct {
Enabled bool
ApiKey string
Secret string
ApiKey string //nolint:gosec
Secret string //nolint:gosec
Language string
ScrobbleFirstArtistOnly bool
@@ -183,7 +183,7 @@ type lastfmOptions struct {
type spotifyOptions struct {
ID string
Secret string
Secret string //nolint:gosec
}
type deezerOptions struct {
@@ -208,7 +208,7 @@ type httpHeaderOptions struct {
type prometheusOptions struct {
Enabled bool
MetricsPath string
Password string
Password string //nolint:gosec
}
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 {
if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec
return cfgFile
}
}

View File

@@ -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)
resp, err := hc.Do(req) //nolint:gosec
if err != nil {
return nil, "", err
}

View File

@@ -108,7 +108,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(req)
resp, err := hc.Do(req) //nolint:gosec
if err != nil {
log.Trace(ctx, "Could not send Insights data", err)
return

View File

@@ -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 {
if _, err := os.Stat(path); err != nil { //nolint:gosec
return nil, fmt.Errorf("%w: %s", err, path)
}
return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil

6
go.mod
View File

@@ -7,7 +7,7 @@ replace (
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
// Fork to implement raw tags support
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260220032326-c5973f82d98a
)
require (
@@ -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.33
github.com/mattn/go-sqlite3 v1.14.34
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.11.0
github.com/pocketbase/dbx v1.12.0
github.com/pressly/goose/v3 v3.26.0
github.com/prometheus/client_golang v1.23.2
github.com/rjeczalik/notify v0.9.3

12
go.sum
View File

@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e h1:pwx3kmHzl1N28coJV2C1zfm2ZF0qkQcGX+Z6BvXteB4=
github.com/deluan/go-taglib v0.0.0-20260212150743-3f1b97cb0d1e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/go-taglib v0.0.0-20260220032326-c5973f82d98a h1:2RzbQ2iFX+A5eDAswk00p6wXzsw1OiCcyHE5Pbj6VIU=
github.com/deluan/go-taglib v0.0.0-20260220032326-c5973f82d98a/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
@@ -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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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/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.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.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=

View File

@@ -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"`
AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"`
HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"`
TrackNumber int `structs:"track_number" json:"trackNumber"`
DiscNumber int `structs:"disc_number" json:"discNumber"`

View File

@@ -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"`
NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec
// If changing the password, this is also required
CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"`
}

View File

@@ -138,7 +138,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
"missing": booleanFilter,
"library_id": artistLibraryIdFilter,
})
r.setSortMappings(map[string]string{
r.setSortMappings(map[string]string{ //nolint:gosec
"name": "order_artist_name",
"starred_at": "starred, starred_at",
"rated_at": "rating, rated_at",

View File

@@ -148,7 +148,9 @@ func (r *mediaFileRepository) Exists(id string) (bool, error) {
}
func (r *mediaFileRepository) Put(m *model.MediaFile) error {
m.CreatedAt = time.Now()
if m.CreatedAt.IsZero() {
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

View File

@@ -104,6 +104,68 @@ 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())

View File

@@ -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())
os.Remove(tmpFile.Name()) //nolint:gosec
return "", fmt.Errorf("failed to write to temp file: %w", err)
}
}

View File

@@ -2,6 +2,7 @@ package scanner
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
@@ -267,6 +268,10 @@ 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
@@ -298,6 +303,14 @@ 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
}

View File

@@ -724,6 +724,120 @@ 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

View File

@@ -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 // No need to close resp.Body, it will be closed via the CachedStream wrapper
resp, err := c.Do(req) //nolint:bodyclose,gosec // 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

View File

@@ -24,8 +24,9 @@ type Broker interface {
const (
keepAliveFrequency = 15 * time.Second
writeTimeOut = 5 * time.Second
bufferSize = 1
// The timeout must be higher than the keepAliveFrequency, or the lack of activity will cause the channel to close.
writeTimeOut = keepAliveFrequency + 5*time.Second
bufferSize = 1
)
type (
@@ -104,7 +105,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)
_, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data) //nolint:gosec
if err != nil {
return err
}

View File

@@ -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 {
if _, err := w.Write(response); err != nil { //nolint:gosec
log.Error(ctx, "Error sending response to client", err)
}
}

View File

@@ -207,7 +207,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
_, err = w.Write(resp)
_, err = w.Write(resp) //nolint:gosec
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -243,7 +243,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) + `}`))
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) //nolint:gosec
} else {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`))
}

View File

@@ -19,47 +19,33 @@ import (
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
func getPlaylist(ds model.DataStore) http.HandlerFunc {
// 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)
}
}
func playlistTracksHandler(ds model.DataStore, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("accept")
if strings.ToLower(accept) == "audio/x-mpegurl" {
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
})
return func(w http.ResponseWriter, r *http.Request) {
if strings.ToLower(r.Header.Get("accept")) == "audio/x-mpegurl" {
handleExportPlaylist(ds)(w, r)
return
}
wrapper(rest.GetAll)(w, r)
handler(w, r)
}
}
func getPlaylistTrack(ds model.DataStore) http.HandlerFunc {
// 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)
return playlistTracksHandler(ds, rest.Get, func(*http.Request) bool { return true })
}
func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
@@ -73,7 +59,7 @@ func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc {
return
}
w.WriteHeader(http.StatusCreated)
_, err = w.Write([]byte(pls.ToM3U8()))
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
if err != nil {
log.Error(ctx, "Error sending m3u contents", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -104,7 +90,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()))
_, err = w.Write([]byte(pls.ToM3U8())) //nolint:gosec
if err != nil {
log.Error(ctx, "Error sending playlist", "name", pls.Name)
return
@@ -176,7 +162,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)
_, err = fmt.Fprintf(w, `{"added":%d}`, count) //nolint:gosec
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -218,7 +204,7 @@ func reorderItem(ds model.DataStore) http.HandlerFunc {
return
}
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id))
_, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id)) //nolint:gosec
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
@@ -239,6 +225,6 @@ func getSongPlaylists(ds model.DataStore) http.HandlerFunc {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = w.Write(data)
_, _ = w.Write(data) //nolint:gosec
}
}

View File

@@ -0,0 +1,167 @@
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))
})
})
})

View File

@@ -87,7 +87,7 @@ func getQueue(ds model.DataStore) http.HandlerFunc {
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(resp)
_, _ = w.Write(resp) //nolint:gosec
}
}

View File

@@ -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()))
_, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec
}
func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) {

View File

@@ -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)
keyData, err := os.ReadFile(keyFile) //nolint:gosec
if err != nil {
return fmt.Errorf("reading TLS key file: %w", err)
}

View File

@@ -363,7 +363,7 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub
}
}
if _, err := w.Write(response); err != nil {
if _, err := w.Write(response); err != nil { //nolint:gosec
log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err)
}
}

View File

@@ -21,6 +21,7 @@ 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) {
@@ -142,6 +143,32 @@ 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 {

View File

@@ -8,8 +8,9 @@ import (
type MockPlaylistRepo struct {
model.PlaylistRepository
Entity *model.Playlist
Error error
Entity *model.Playlist
Error error
TracksReturn model.PlaylistTrackRepository
}
func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) {
@@ -22,6 +23,10 @@ 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

View File

@@ -127,10 +127,12 @@ 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]))
})
@@ -145,6 +147,7 @@ const reducePlayNext = (state, { data }) => {
return {
...state,
queue: newQueue,
playIndex: foundPos ? currentIndex : undefined,
clear: true,
}
}

View File

@@ -97,6 +97,16 @@ export default {
boxShadow: '3px 3px 5px #3c3836',
},
},
MuiSwitch: {
colorSecondary: {
'&$checked': {
color: '#458588',
},
'&$checked + $track': {
backgroundColor: '#458588',
},
},
},
NDMobileArtistDetails: {
bgContainer: {
background: