mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-20 08:04:43 -05:00
Compare commits
12 Commits
plugins-en
...
chore/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a91d7e9453 | ||
|
|
e033189b6f | ||
|
|
6025746070 | ||
|
|
d946ecd981 | ||
|
|
8d9143e80e | ||
|
|
08a71320ea | ||
|
|
44a5482493 | ||
|
|
5fa8356b31 | ||
|
|
cad9cdc53e | ||
|
|
b774133cd1 | ||
|
|
a20d56c137 | ||
|
|
b64d8ad334 |
@@ -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 \
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
2
.github/workflows/pipeline.yml
vendored
2
.github/workflows/pipeline.yml
vendored
@@ -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' }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
10
Makefile
10
Makefile
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ type listenBrainzResponse struct {
|
||||
}
|
||||
|
||||
type listenBrainzRequest struct {
|
||||
ApiKey string
|
||||
ApiKey string //nolint:gosec
|
||||
Body listenBrainzRequestBody
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
6
go.mod
@@ -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
12
go.sum
@@ -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=
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
167
server/nativeapi/playlists_test.go
Normal file
167
server/nativeapi/playlists_test.go
Normal 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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,16 @@ export default {
|
||||
boxShadow: '3px 3px 5px #3c3836',
|
||||
},
|
||||
},
|
||||
MuiSwitch: {
|
||||
colorSecondary: {
|
||||
'&$checked': {
|
||||
color: '#458588',
|
||||
},
|
||||
'&$checked + $track': {
|
||||
backgroundColor: '#458588',
|
||||
},
|
||||
},
|
||||
},
|
||||
NDMobileArtistDetails: {
|
||||
bgContainer: {
|
||||
background:
|
||||
|
||||
Reference in New Issue
Block a user